From 5eaba329181ace036248e54b277a947321f0a2ba Mon Sep 17 00:00:00 2001 From: Dorian Date: Sun, 8 Mar 2026 00:09:46 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20N9=20audio=20polish=20=E2=80=94=20speec?= =?UTF-8?q?h=20robustness,=20crossfade,=20volume=20balancing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- frontend/src/game/sounds.ts | 41 ++++++++++++++++++++++++++++++++++--- 1 file changed, 38 insertions(+), 3 deletions(-) diff --git a/frontend/src/game/sounds.ts b/frontend/src/game/sounds.ts index da50175..023b5d3 100644 --- a/frontend/src/game/sounds.ts +++ b/frontend/src/game/sounds.ts @@ -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