diff --git a/frontend/src/components/FightViewer.vue b/frontend/src/components/FightViewer.vue index cc84eea..4529490 100644 --- a/frontend/src/components/FightViewer.vue +++ b/frontend/src/components/FightViewer.vue @@ -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() {
-

{{ announcement }} @@ -524,7 +524,7 @@ async function replay() { class="absolute pointer-events-none z-30" :style="{ left: `${hitTextX}%`, top: `${hitTextY}%`, transform: 'translate(-50%, -50%)' }">

-

{{ hitText }} diff --git a/frontend/src/game/sounds.ts b/frontend/src/game/sounds.ts index 8b816d2..263ab40 100644 --- a/frontend/src/game/sounds.ts +++ b/frontend/src/game/sounds.ts @@ -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 diff --git a/frontend/src/pages/FightPage.vue b/frontend/src/pages/FightPage.vue index 70358af..f904415 100644 --- a/frontend/src/pages/FightPage.vue +++ b/frontend/src/pages/FightPage.vue @@ -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() {

-

{{ liveAnnouncement }}