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.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
|
||||
|
||||
Reference in New Issue
Block a user