fix: audio reliability — proper AudioContext resume, SFX destination, voice stability
- getSfxDest() helper centralizes SFX routing (replaces 38 inline calls) - Await ensureAudioContext() before playing audio (fixes suspended context on prod) - Chrome speechSynthesis pause bug workaround (periodic resume interval) - Respect masterMuted on initial gain node creation - Responsive overlay text sizes for small screens (text-2xl/5xl/7xl) - Speech queue depth management with safety flush at depth > 3 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
07fa2cf35c
commit
20e9f0f7a2
@@ -63,9 +63,9 @@ const hitTextY = ref(30)
|
||||
const glitching = ref(false)
|
||||
const soundOn = ref(true)
|
||||
|
||||
function toggleSound() {
|
||||
async function toggleSound() {
|
||||
soundOn.value = !soundOn.value
|
||||
ensureAudioContext()
|
||||
await ensureAudioContext()
|
||||
setMasterMute(!soundOn.value)
|
||||
}
|
||||
|
||||
@@ -210,7 +210,7 @@ async function replay() {
|
||||
|
||||
await initScene()
|
||||
if (soundOn.value) {
|
||||
ensureAudioContext()
|
||||
await ensureAudioContext()
|
||||
scene?.startMusic()
|
||||
}
|
||||
await sleep(300)
|
||||
@@ -509,7 +509,7 @@ async function replay() {
|
||||
<div v-if="announcementVisible"
|
||||
class="absolute inset-0 flex items-center justify-center pointer-events-none z-20">
|
||||
<div class="announce-text-wrapper">
|
||||
<p class="font-funky text-5xl sm:text-7xl tracking-widest announce-text uppercase announce-chromatic"
|
||||
<p class="font-funky text-2xl sm:text-5xl lg:text-7xl tracking-widest announce-text uppercase announce-chromatic"
|
||||
:data-text="announcement"
|
||||
:style="{ color: announcementColor, textShadow: `0 0 20px ${announcementColor}, 0 0 40px ${announcementColor}, 0 0 80px ${announcementColor}40, 0 0 120px ${announcementColor}20` }">
|
||||
{{ announcement }}
|
||||
@@ -524,7 +524,7 @@ async function replay() {
|
||||
class="absolute pointer-events-none z-30"
|
||||
:style="{ left: `${hitTextX}%`, top: `${hitTextY}%`, transform: 'translate(-50%, -50%)' }">
|
||||
<div class="hit-text-wrapper">
|
||||
<p class="font-neon text-4xl sm:text-5xl tracking-wider hit-text hit-chromatic"
|
||||
<p class="font-neon text-xl sm:text-4xl lg:text-5xl tracking-wider hit-text hit-chromatic"
|
||||
:data-text="hitText"
|
||||
:style="{ color: hitTextColor, textShadow: `0 0 15px ${hitTextColor}, 0 0 30px ${hitTextColor}, 0 0 60px ${hitTextColor}60` }">
|
||||
{{ hitText }}
|
||||
|
||||
+70
-46
@@ -9,16 +9,24 @@ function getCtx(): AudioContext {
|
||||
if (!ctx) {
|
||||
ctx = new AudioContext()
|
||||
musicGain = ctx.createGain()
|
||||
musicGain.gain.value = 0.12
|
||||
musicGain.gain.value = masterMuted ? 0 : 0.12
|
||||
musicGain.connect(ctx.destination)
|
||||
sfxGain = ctx.createGain()
|
||||
sfxGain.gain.value = 0.25
|
||||
sfxGain.gain.value = masterMuted ? 0 : 0.25
|
||||
sfxGain.connect(ctx.destination)
|
||||
}
|
||||
if (ctx.state === 'suspended') ctx.resume()
|
||||
if (ctx.state === 'suspended') {
|
||||
ctx.resume().catch(() => {})
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
// Get the SFX destination node (gain is 0 when muted, so audio is silent but context stays valid)
|
||||
function getSfxDest(): AudioNode {
|
||||
const c = getCtx()
|
||||
return sfxGain ?? c.destination
|
||||
}
|
||||
|
||||
function tone(freq: number, type: OscillatorType, duration: number, dest: AudioNode, startTime?: number) {
|
||||
const c = getCtx()
|
||||
const osc = c.createOscillator()
|
||||
@@ -263,6 +271,12 @@ function loadVoices() {
|
||||
if (typeof speechSynthesis !== 'undefined') {
|
||||
speechSynthesis.onvoiceschanged = loadVoices
|
||||
loadVoices()
|
||||
// Chrome bug workaround: speechSynthesis pauses after ~15s.
|
||||
// Periodic resume() keeps it alive.
|
||||
setInterval(() => {
|
||||
if (speechSynthesis.speaking && !speechSynthesis.paused) return
|
||||
if (speechSynthesis.paused) speechSynthesis.resume()
|
||||
}, 5000)
|
||||
}
|
||||
|
||||
let _speechQueueDepth = 0
|
||||
@@ -271,11 +285,14 @@ function speak(text: string, profileName: string, cancelPrevious: boolean = fals
|
||||
if (typeof speechSynthesis === 'undefined') return
|
||||
if (masterMuted) return
|
||||
if (!voicesLoaded) loadVoices()
|
||||
// Chrome bug: speechSynthesis can get stuck. Nudge it.
|
||||
if (speechSynthesis.paused) speechSynthesis.resume()
|
||||
// Only flush if queue is getting deep — allows voices to overlap naturally
|
||||
if (cancelPrevious || speechSynthesis.pending && speechSynthesis.speaking) {
|
||||
// 2+ queued: drop the queue but let current utterance finish
|
||||
const queued = _speechQueueDepth
|
||||
if (queued > 3) speechSynthesis.cancel()
|
||||
if (cancelPrevious || (speechSynthesis.pending && speechSynthesis.speaking)) {
|
||||
if (_speechQueueDepth > 3) {
|
||||
speechSynthesis.cancel()
|
||||
_speechQueueDepth = 0
|
||||
}
|
||||
}
|
||||
const profile = voiceProfiles[profileName] || voiceProfiles.announcer
|
||||
const utter = new SpeechSynthesisUtterance(text)
|
||||
@@ -448,7 +465,7 @@ export function announceRoundHype() {
|
||||
// === FANFARES (8-bit melodic announcements) ===
|
||||
|
||||
export function fanfareRound(roundNum: number) {
|
||||
const d = sfxGain ?? getCtx().destination
|
||||
const d = getSfxDest()
|
||||
const c = getCtx()
|
||||
const t = c.currentTime
|
||||
// Ascending power chord
|
||||
@@ -462,7 +479,7 @@ export function fanfareRound(roundNum: number) {
|
||||
}
|
||||
|
||||
export function fanfareFight() {
|
||||
const d = sfxGain ?? getCtx().destination
|
||||
const d = getSfxDest()
|
||||
const c = getCtx()
|
||||
const t = c.currentTime
|
||||
// Punchy staccato
|
||||
@@ -474,7 +491,7 @@ export function fanfareFight() {
|
||||
}
|
||||
|
||||
export function fanfareDevastating() {
|
||||
const d = sfxGain ?? getCtx().destination
|
||||
const d = getSfxDest()
|
||||
const c = getCtx()
|
||||
const t = c.currentTime
|
||||
tone(220, 'sawtooth', 0.2, d, t)
|
||||
@@ -484,7 +501,7 @@ export function fanfareDevastating() {
|
||||
}
|
||||
|
||||
export function fanfareCritical() {
|
||||
const d = sfxGain ?? getCtx().destination
|
||||
const d = getSfxDest()
|
||||
const c = getCtx()
|
||||
const t = c.currentTime
|
||||
tone(440, 'square', 0.1, d, t)
|
||||
@@ -496,7 +513,7 @@ export function fanfareCritical() {
|
||||
}
|
||||
|
||||
export function fanfareCombo(count: number) {
|
||||
const d = sfxGain ?? getCtx().destination
|
||||
const d = getSfxDest()
|
||||
const c = getCtx()
|
||||
const t = c.currentTime
|
||||
for (let i = 0; i < Math.min(count, 5); i++) {
|
||||
@@ -585,7 +602,7 @@ function fmImpact(carrier: number, modRatio: number, duration: number, dest: Aud
|
||||
}
|
||||
|
||||
export function sfxPunch() {
|
||||
const d = sfxGain ?? getCtx().destination
|
||||
const d = getSfxDest()
|
||||
const c = getCtx(); const t = c.currentTime
|
||||
// Heavy body thump
|
||||
bodyThump(120, 0.12, d, t, 0.3)
|
||||
@@ -598,7 +615,7 @@ export function sfxPunch() {
|
||||
}
|
||||
|
||||
export function sfxKick() {
|
||||
const d = sfxGain ?? getCtx().destination
|
||||
const d = getSfxDest()
|
||||
const c = getCtx(); const t = c.currentTime
|
||||
// Deep bass slam
|
||||
bodyThump(80, 0.18, d, t, 0.35)
|
||||
@@ -611,7 +628,7 @@ export function sfxKick() {
|
||||
}
|
||||
|
||||
export function sfxSpecial() {
|
||||
const d = sfxGain ?? getCtx().destination
|
||||
const d = getSfxDest()
|
||||
const c = getCtx(); const t = c.currentTime
|
||||
// Power charge sweep
|
||||
sweep(150, 800, 'sawtooth', 0.2, d)
|
||||
@@ -626,7 +643,7 @@ export function sfxSpecial() {
|
||||
}
|
||||
|
||||
export function sfxCritical() {
|
||||
const d = sfxGain ?? getCtx().destination
|
||||
const d = getSfxDest()
|
||||
const c = getCtx(); const t = c.currentTime
|
||||
// Massive bass hit
|
||||
bodyThump(60, 0.3, d, t, 0.4)
|
||||
@@ -648,7 +665,7 @@ export function sfxCritical() {
|
||||
}
|
||||
|
||||
export function sfxGunshot() {
|
||||
const d = sfxGain ?? getCtx().destination
|
||||
const d = getSfxDest()
|
||||
const c = getCtx(); const t = c.currentTime
|
||||
// Sharp transient
|
||||
highSnap(6000, 0.015, d, t)
|
||||
@@ -662,7 +679,7 @@ export function sfxGunshot() {
|
||||
}
|
||||
|
||||
export function sfxBulletHit() {
|
||||
const d = sfxGain ?? getCtx().destination
|
||||
const d = getSfxDest()
|
||||
const c = getCtx(); const t = c.currentTime
|
||||
bodyThump(150, 0.06, d, t, 0.2)
|
||||
highSnap(4000, 0.02, d, t)
|
||||
@@ -671,7 +688,7 @@ export function sfxBulletHit() {
|
||||
}
|
||||
|
||||
export function sfxJetpack() {
|
||||
const d = sfxGain ?? getCtx().destination
|
||||
const d = getSfxDest()
|
||||
const c = getCtx(); const t = c.currentTime
|
||||
// Turbine rumble — layered oscillators with modulation
|
||||
const osc1 = c.createOscillator(); osc1.type = 'sawtooth'
|
||||
@@ -698,7 +715,7 @@ export function sfxJetpack() {
|
||||
}
|
||||
|
||||
export function sfxExplosion() {
|
||||
const d = sfxGain ?? getCtx().destination
|
||||
const d = getSfxDest()
|
||||
const c = getCtx(); const t = c.currentTime
|
||||
// Massive sub thump
|
||||
bodyThump(40, 0.5, d, t, 0.4)
|
||||
@@ -720,7 +737,7 @@ export function sfxExplosion() {
|
||||
}
|
||||
|
||||
export function sfxBlock() {
|
||||
const d = sfxGain ?? getCtx().destination
|
||||
const d = getSfxDest()
|
||||
const c = getCtx(); const t = c.currentTime
|
||||
// Metallic shield ring
|
||||
fmImpact(800, 1.5, 0.12, d, t)
|
||||
@@ -730,7 +747,7 @@ export function sfxBlock() {
|
||||
}
|
||||
|
||||
export function sfxDodge() {
|
||||
const d = sfxGain ?? getCtx().destination
|
||||
const d = getSfxDest()
|
||||
const c = getCtx(); const t = c.currentTime
|
||||
// Quick air whoosh
|
||||
sweep(300, 1200, 'sine', 0.1, d)
|
||||
@@ -740,7 +757,7 @@ export function sfxDodge() {
|
||||
}
|
||||
|
||||
export function sfxClash() {
|
||||
const d = sfxGain ?? getCtx().destination
|
||||
const d = getSfxDest()
|
||||
const c = getCtx(); const t = c.currentTime
|
||||
// Two impacts overlapping
|
||||
bodyThump(100, 0.1, d, t, 0.25)
|
||||
@@ -754,7 +771,7 @@ export function sfxClash() {
|
||||
// === KO + WIN ===
|
||||
|
||||
export function sfxKO() {
|
||||
const d = sfxGain ?? getCtx().destination
|
||||
const d = getSfxDest()
|
||||
const c = getCtx(); const t = c.currentTime
|
||||
// Earth-shaking bass
|
||||
bodyThump(30, 0.6, d, t, 0.5)
|
||||
@@ -774,7 +791,7 @@ export function sfxKO() {
|
||||
}
|
||||
|
||||
export function sfxPerfect() {
|
||||
const d = sfxGain ?? getCtx().destination
|
||||
const d = getSfxDest()
|
||||
const c = getCtx(); const t = c.currentTime
|
||||
// Massive descending power
|
||||
bodyThump(30, 0.8, d, t, 0.45)
|
||||
@@ -793,7 +810,7 @@ export function sfxPerfect() {
|
||||
}
|
||||
|
||||
export function sfxWin() {
|
||||
const d = sfxGain ?? getCtx().destination
|
||||
const d = getSfxDest()
|
||||
const c = getCtx(); const t = c.currentTime
|
||||
// Victory jingle — richer with harmonics and FM shimmer
|
||||
const melody = [
|
||||
@@ -821,7 +838,7 @@ export function sfxWinAnnounce(winnerName: string) {
|
||||
}
|
||||
|
||||
export function sfxRoundStart() {
|
||||
const d = sfxGain ?? getCtx().destination
|
||||
const d = getSfxDest()
|
||||
const c = getCtx(); const t = c.currentTime
|
||||
// Bell-like tones with FM shimmer
|
||||
fmImpact(440, 2, 0.2, d, t)
|
||||
@@ -836,7 +853,7 @@ export function sfxRoundStart() {
|
||||
// === SILLY / FUNNY SOUNDS ===
|
||||
|
||||
export function sfxBoing() {
|
||||
const d = sfxGain ?? getCtx().destination
|
||||
const d = getSfxDest()
|
||||
const c = getCtx(); const t = c.currentTime
|
||||
// Spring with resonance
|
||||
sweep(200, 900, 'sine', 0.12, d)
|
||||
@@ -846,7 +863,7 @@ export function sfxBoing() {
|
||||
}
|
||||
|
||||
export function sfxWomp() {
|
||||
const d = sfxGain ?? getCtx().destination
|
||||
const d = getSfxDest()
|
||||
const c = getCtx(); const t = c.currentTime
|
||||
// Sad trombone — richer with vibrato
|
||||
const notes = [[294, 0.25], [277, 0.25], [262, 0.25], [247, 0.5]] as [number, number][]
|
||||
@@ -860,19 +877,19 @@ export function sfxWomp() {
|
||||
}
|
||||
|
||||
export function sfxSlideUp() {
|
||||
const d = sfxGain ?? getCtx().destination
|
||||
const d = getSfxDest()
|
||||
sweep(200, 2000, 'sine', 0.25, d)
|
||||
sweep(210, 2100, 'sine', 0.25, d) // chorus
|
||||
}
|
||||
|
||||
export function sfxSlideDown() {
|
||||
const d = sfxGain ?? getCtx().destination
|
||||
const d = getSfxDest()
|
||||
sweep(2000, 100, 'sine', 0.35, d)
|
||||
sweep(2020, 110, 'sine', 0.35, d)
|
||||
}
|
||||
|
||||
export function sfxBonk() {
|
||||
const d = sfxGain ?? getCtx().destination
|
||||
const d = getSfxDest()
|
||||
const c = getCtx(); const t = c.currentTime
|
||||
bodyThump(200, 0.06, d, t, 0.2)
|
||||
fmImpact(800, 3, 0.06, d, t)
|
||||
@@ -881,7 +898,7 @@ export function sfxBonk() {
|
||||
}
|
||||
|
||||
export function sfxSplat() {
|
||||
const d = sfxGain ?? getCtx().destination
|
||||
const d = getSfxDest()
|
||||
const c = getCtx(); const t = c.currentTime
|
||||
bodyThump(100, 0.1, d, t, 0.2)
|
||||
noise(0.12, d, t)
|
||||
@@ -891,7 +908,7 @@ export function sfxSplat() {
|
||||
}
|
||||
|
||||
export function sfxZap() {
|
||||
const d = sfxGain ?? getCtx().destination
|
||||
const d = getSfxDest()
|
||||
const c = getCtx(); const t = c.currentTime
|
||||
// Electric arc
|
||||
sweep(100, 4000, 'sawtooth', 0.06, d)
|
||||
@@ -902,7 +919,7 @@ export function sfxZap() {
|
||||
}
|
||||
|
||||
export function sfxZoomWhoosh() {
|
||||
const d = sfxGain ?? getCtx().destination
|
||||
const d = getSfxDest()
|
||||
const c = getCtx(); const t = c.currentTime
|
||||
// Layered Doppler whoosh
|
||||
sweep(80, 1800, 'sawtooth', 0.2, d)
|
||||
@@ -917,7 +934,7 @@ export function sfxZoomWhoosh() {
|
||||
}
|
||||
|
||||
export function sfxRapidPunch() {
|
||||
const d = sfxGain ?? getCtx().destination
|
||||
const d = getSfxDest()
|
||||
const c = getCtx(); const t = c.currentTime
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const ht = t + i * 0.04
|
||||
@@ -928,7 +945,7 @@ export function sfxRapidPunch() {
|
||||
}
|
||||
|
||||
export function sfxPowerUp() {
|
||||
const d = sfxGain ?? getCtx().destination
|
||||
const d = getSfxDest()
|
||||
const c = getCtx(); const t = c.currentTime
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const freq = 300 + i * 100
|
||||
@@ -939,7 +956,7 @@ export function sfxPowerUp() {
|
||||
}
|
||||
|
||||
export function sfxCoin() {
|
||||
const d = sfxGain ?? getCtx().destination
|
||||
const d = getSfxDest()
|
||||
const c = getCtx(); const t = c.currentTime
|
||||
fmImpact(988, 1.5, 0.1, d, t)
|
||||
fmImpact(1319, 1.5, 0.15, d, t + 0.06)
|
||||
@@ -948,7 +965,7 @@ export function sfxCoin() {
|
||||
}
|
||||
|
||||
export function sfxFail() {
|
||||
const d = sfxGain ?? getCtx().destination
|
||||
const d = getSfxDest()
|
||||
const c = getCtx(); const t = c.currentTime
|
||||
sweep(600, 80, 'sawtooth', 0.25, d)
|
||||
bodyThump(80, 0.2, d, t + 0.15, 0.15)
|
||||
@@ -1828,8 +1845,15 @@ export function isMasterMuted(): boolean {
|
||||
return masterMuted
|
||||
}
|
||||
|
||||
export function ensureAudioContext() {
|
||||
getCtx()
|
||||
export async function ensureAudioContext() {
|
||||
const c = getCtx()
|
||||
if (c.state === 'suspended') {
|
||||
try { await c.resume() } catch {}
|
||||
}
|
||||
// Prime speech synthesis on user gesture — some browsers need this
|
||||
if (typeof speechSynthesis !== 'undefined' && !voicesLoaded) {
|
||||
loadVoices()
|
||||
}
|
||||
}
|
||||
|
||||
// === CROWD SOUNDS ===
|
||||
@@ -1837,7 +1861,7 @@ export function ensureAudioContext() {
|
||||
|
||||
export function sfxCrowdOoh() {
|
||||
const c = getCtx()
|
||||
const d = sfxGain ?? c.destination
|
||||
const d = getSfxDest()
|
||||
const t = c.currentTime
|
||||
// Rising "ooh" — filtered noise sweep
|
||||
for (let i = 0; i < 3; i++) {
|
||||
@@ -1857,7 +1881,7 @@ export function sfxCrowdOoh() {
|
||||
|
||||
export function sfxCrowdGasp() {
|
||||
const c = getCtx()
|
||||
const d = sfxGain ?? c.destination
|
||||
const d = getSfxDest()
|
||||
const t = c.currentTime
|
||||
// Sharp intake — high noise burst + quick sine chirps
|
||||
const bufSize = Math.max(1, Math.floor(c.sampleRate * 0.15))
|
||||
@@ -1886,7 +1910,7 @@ export function sfxCrowdGasp() {
|
||||
|
||||
export function sfxCrowdCheer() {
|
||||
const c = getCtx()
|
||||
const d = sfxGain ?? c.destination
|
||||
const d = getSfxDest()
|
||||
const t = c.currentTime
|
||||
// Layered noise + sine clusters = roaring crowd
|
||||
for (let layer = 0; layer < 3; layer++) {
|
||||
@@ -1909,7 +1933,7 @@ export function sfxCrowdCheer() {
|
||||
|
||||
export function sfxApplause() {
|
||||
const c = getCtx()
|
||||
const d = sfxGain ?? c.destination
|
||||
const d = getSfxDest()
|
||||
const t = c.currentTime
|
||||
// Crackling filtered noise = many hands clapping
|
||||
for (let burst = 0; burst < 6; burst++) {
|
||||
@@ -1930,7 +1954,7 @@ export function sfxApplause() {
|
||||
|
||||
export function sfxDrumRoll() {
|
||||
const c = getCtx()
|
||||
const d = sfxGain ?? c.destination
|
||||
const d = getSfxDest()
|
||||
const t = c.currentTime
|
||||
// Rapid snare hits building in intensity
|
||||
const hits = 16
|
||||
|
||||
@@ -268,7 +268,7 @@ async function initLiveScene() {
|
||||
|
||||
if (liveScene) {
|
||||
if (liveSoundOn.value) {
|
||||
ensureAudioContext()
|
||||
await ensureAudioContext()
|
||||
liveScene.startMusic()
|
||||
}
|
||||
announceDeepIntro()
|
||||
@@ -517,9 +517,9 @@ async function handleFightEnd(data: any) {
|
||||
if (pollHandle) { clearInterval(pollHandle); pollHandle = null }
|
||||
}
|
||||
|
||||
function toggleLiveSound() {
|
||||
async function toggleLiveSound() {
|
||||
liveSoundOn.value = !liveSoundOn.value
|
||||
ensureAudioContext()
|
||||
await ensureAudioContext()
|
||||
setMasterMute(!liveSoundOn.value)
|
||||
}
|
||||
|
||||
@@ -829,7 +829,7 @@ function stopAutoBattle() {
|
||||
<Transition name="announce">
|
||||
<div v-if="liveAnnouncementVisible"
|
||||
class="absolute inset-0 flex items-center justify-center pointer-events-none z-20">
|
||||
<p class="font-funky text-5xl sm:text-7xl tracking-widest uppercase announce-text"
|
||||
<p class="font-funky text-2xl sm:text-5xl lg:text-7xl tracking-widest uppercase announce-text"
|
||||
:style="{ color: liveAnnouncementColor, textShadow: `0 0 20px ${liveAnnouncementColor}, 0 0 40px ${liveAnnouncementColor}` }">
|
||||
{{ liveAnnouncement }}
|
||||
</p>
|
||||
|
||||
Reference in New Issue
Block a user