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 }}