feat: N9 audio polish — speech robustness, crossfade, volume balancing
- visibilitychange handler resumes speech + AudioContext on tab return - speak() retry logic: re-attempts once on non-cancel errors after 200ms - VOICE_VOLUME_SCALE (0.7) prevents voice from overpowering SFX/music - Music crossfade: brief gain dip (40% -> 100%) on track switches - Applied volume scale to all speech paths (speak, announce custom) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
bc48cc8887
commit
5eaba32918
@@ -280,9 +280,20 @@ if (typeof speechSynthesis !== 'undefined') {
|
|||||||
if (speechSynthesis.speaking && !speechSynthesis.paused) return
|
if (speechSynthesis.speaking && !speechSynthesis.paused) return
|
||||||
if (speechSynthesis.paused) speechSynthesis.resume()
|
if (speechSynthesis.paused) speechSynthesis.resume()
|
||||||
}, 5000)
|
}, 5000)
|
||||||
|
// Resume speech + audio when tab becomes visible again
|
||||||
|
if (typeof document !== 'undefined') {
|
||||||
|
document.addEventListener('visibilitychange', () => {
|
||||||
|
if (document.visibilityState === 'visible') {
|
||||||
|
if (speechSynthesis.paused) speechSynthesis.resume()
|
||||||
|
if (ctx?.state === 'suspended') ctx.resume().catch(() => {})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let _speechQueueDepth = 0
|
let _speechQueueDepth = 0
|
||||||
|
// Scale speech volume down so voice doesn't overpower SFX/music
|
||||||
|
const VOICE_VOLUME_SCALE = 0.7
|
||||||
|
|
||||||
function speak(text: string, profileName: string, cancelPrevious: boolean = false, _echo: boolean = false) {
|
function speak(text: string, profileName: string, cancelPrevious: boolean = false, _echo: boolean = false) {
|
||||||
if (typeof speechSynthesis === 'undefined') return
|
if (typeof speechSynthesis === 'undefined') return
|
||||||
@@ -302,10 +313,26 @@ function speak(text: string, profileName: string, cancelPrevious: boolean = fals
|
|||||||
if (profile.voice) utter.voice = profile.voice
|
if (profile.voice) utter.voice = profile.voice
|
||||||
utter.pitch = profile.pitch
|
utter.pitch = profile.pitch
|
||||||
utter.rate = profile.rate
|
utter.rate = profile.rate
|
||||||
utter.volume = profile.volume
|
utter.volume = profile.volume * VOICE_VOLUME_SCALE
|
||||||
_speechQueueDepth++
|
_speechQueueDepth++
|
||||||
utter.onend = () => { _speechQueueDepth = Math.max(0, _speechQueueDepth - 1) }
|
utter.onend = () => { _speechQueueDepth = Math.max(0, _speechQueueDepth - 1) }
|
||||||
utter.onerror = () => { _speechQueueDepth = Math.max(0, _speechQueueDepth - 1) }
|
utter.onerror = (ev) => {
|
||||||
|
_speechQueueDepth = Math.max(0, _speechQueueDepth - 1)
|
||||||
|
// Retry once on non-cancel errors (interrupted = browser killed it, not us)
|
||||||
|
if (ev.error !== 'canceled' && ev.error !== 'interrupted') {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!masterMuted && typeof speechSynthesis !== 'undefined') {
|
||||||
|
const retry = new SpeechSynthesisUtterance(text)
|
||||||
|
if (profile.voice) retry.voice = profile.voice
|
||||||
|
retry.pitch = profile.pitch; retry.rate = profile.rate; retry.volume = profile.volume * VOICE_VOLUME_SCALE
|
||||||
|
_speechQueueDepth++
|
||||||
|
retry.onend = () => { _speechQueueDepth = Math.max(0, _speechQueueDepth - 1) }
|
||||||
|
retry.onerror = () => { _speechQueueDepth = Math.max(0, _speechQueueDepth - 1) }
|
||||||
|
speechSynthesis.speak(retry)
|
||||||
|
}
|
||||||
|
}, 200)
|
||||||
|
}
|
||||||
|
}
|
||||||
speechSynthesis.speak(utter)
|
speechSynthesis.speak(utter)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -326,7 +353,7 @@ export function announce(text: string, pitch?: number, rate?: number) {
|
|||||||
if (profile.voice) utter.voice = profile.voice
|
if (profile.voice) utter.voice = profile.voice
|
||||||
utter.pitch = pitch ?? 1.0
|
utter.pitch = pitch ?? 1.0
|
||||||
utter.rate = rate ?? 0.8
|
utter.rate = rate ?? 0.8
|
||||||
utter.volume = 1.0
|
utter.volume = VOICE_VOLUME_SCALE
|
||||||
_speechQueueDepth++
|
_speechQueueDepth++
|
||||||
utter.onend = () => { _speechQueueDepth = Math.max(0, _speechQueueDepth - 1) }
|
utter.onend = () => { _speechQueueDepth = Math.max(0, _speechQueueDepth - 1) }
|
||||||
utter.onerror = () => { _speechQueueDepth = Math.max(0, _speechQueueDepth - 1) }
|
utter.onerror = () => { _speechQueueDepth = Math.max(0, _speechQueueDepth - 1) }
|
||||||
@@ -1748,6 +1775,7 @@ function playMusicBar() {
|
|||||||
const others = pool.filter(t => t !== activeTrack)
|
const others = pool.filter(t => t !== activeTrack)
|
||||||
return others.length > 0 ? others[Math.floor(Math.random() * others.length)] : pool[0]
|
return others.length > 0 ? others[Math.floor(Math.random() * others.length)] : pool[0]
|
||||||
}
|
}
|
||||||
|
const prevTrack = activeTrack
|
||||||
if (currentIntensity > 0.7) {
|
if (currentIntensity > 0.7) {
|
||||||
activeTrack = pickFrom(intenseTracks.length > 0 ? intenseTracks : ALL_TRACKS)
|
activeTrack = pickFrom(intenseTracks.length > 0 ? intenseTracks : ALL_TRACKS)
|
||||||
} else if (currentIntensity < 0.3) {
|
} else if (currentIntensity < 0.3) {
|
||||||
@@ -1758,6 +1786,13 @@ function playMusicBar() {
|
|||||||
// Full random for maximum variety
|
// Full random for maximum variety
|
||||||
activeTrack = pickFrom(ALL_TRACKS)
|
activeTrack = pickFrom(ALL_TRACKS)
|
||||||
}
|
}
|
||||||
|
// Smooth crossfade: brief gain dip on track switch to prevent abrupt transition
|
||||||
|
if (activeTrack !== prevTrack && musicGain) {
|
||||||
|
const curVol = musicGain.gain.value
|
||||||
|
musicGain.gain.setValueAtTime(curVol, now)
|
||||||
|
musicGain.gain.linearRampToValueAtTime(curVol * 0.4, now + 0.05)
|
||||||
|
musicGain.gain.linearRampToValueAtTime(curVol, now + 0.15)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Chord pad for the whole bar
|
// Chord pad for the whole bar
|
||||||
|
|||||||
Reference in New Issue
Block a user