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:
Dorian
2026-03-08 00:09:46 +00:00
co-authored by Claude Opus 4.6
parent bc48cc8887
commit 5eaba32918
+38 -3
View File
@@ -280,9 +280,20 @@ if (typeof speechSynthesis !== 'undefined') {
if (speechSynthesis.speaking && !speechSynthesis.paused) return
if (speechSynthesis.paused) speechSynthesis.resume()
}, 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
// 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) {
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
utter.pitch = profile.pitch
utter.rate = profile.rate
utter.volume = profile.volume
utter.volume = profile.volume * VOICE_VOLUME_SCALE
_speechQueueDepth++
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)
}
@@ -326,7 +353,7 @@ export function announce(text: string, pitch?: number, rate?: number) {
if (profile.voice) utter.voice = profile.voice
utter.pitch = pitch ?? 1.0
utter.rate = rate ?? 0.8
utter.volume = 1.0
utter.volume = VOICE_VOLUME_SCALE
_speechQueueDepth++
utter.onend = () => { _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)
return others.length > 0 ? others[Math.floor(Math.random() * others.length)] : pool[0]
}
const prevTrack = activeTrack
if (currentIntensity > 0.7) {
activeTrack = pickFrom(intenseTracks.length > 0 ? intenseTracks : ALL_TRACKS)
} else if (currentIntensity < 0.3) {
@@ -1758,6 +1786,13 @@ function playMusicBar() {
// Full random for maximum variety
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