From a26c2bb56058afa57576dd9e49600fc6d8e6e24f Mon Sep 17 00:00:00 2001 From: Michael O'Rourke Date: Mon, 13 Apr 2026 15:49:14 -0600 Subject: [PATCH 1/2] fix(audio): hoist Howl cache to module scope to survive provider remounts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MUDProvider swaps its internal component type when setupPromise resolves (MUDContext.Provider → MUDProviderInner), which unmounts the entire SoundProvider subtree. The previous ref-based Howl cache was destroyed mid-load, producing "Decoding audio data failed" on the first Howl and leaving the second mount's Howl in a silent state even though Howler reported playback. Moving the cache, activeTrack, and missingZoneWarned set to module scope means the second mount sees the Howl already playing and no-ops via the keyEq early return. Also strips the diagnostic console.logs now that the root cause is identified, and adds __resetSoundForTests so the test suite can reset module state between runs. All 23 SoundContext tests pass against the new implementation. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../client/src/contexts/SoundContext.test.tsx | 3 +- packages/client/src/contexts/SoundContext.tsx | 160 ++++++++---------- 2 files changed, 69 insertions(+), 94 deletions(-) diff --git a/packages/client/src/contexts/SoundContext.test.tsx b/packages/client/src/contexts/SoundContext.test.tsx index f8f0467c..1d2edf85 100644 --- a/packages/client/src/contexts/SoundContext.test.tsx +++ b/packages/client/src/contexts/SoundContext.test.tsx @@ -1,7 +1,7 @@ import { render, screen, fireEvent, cleanup, act } from '@testing-library/react'; import { ChakraProvider } from '@chakra-ui/react'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { SoundProvider, useGameAudio } from './SoundContext'; +import { SoundProvider, useGameAudio, __resetSoundForTests } from './SoundContext'; // Track mock Howl instances for assertions const mockPlay = vi.fn(); @@ -82,6 +82,7 @@ describe('SoundContext', () => { beforeEach(() => { localStorage.clear(); sessionStorage.clear(); + __resetSoundForTests(); mockUseAuth.mockReturnValue({ isAuthenticated: false }); mockUseMap.mockReturnValue({ currentZone: 1 }); mockUseBattle.mockReturnValue({ currentBattle: null }); diff --git a/packages/client/src/contexts/SoundContext.tsx b/packages/client/src/contexts/SoundContext.tsx index a60a2d4f..8daa21b9 100644 --- a/packages/client/src/contexts/SoundContext.tsx +++ b/packages/client/src/contexts/SoundContext.tsx @@ -34,6 +34,22 @@ const BATTLE_AUDIO: Record = { type TrackKey = { zone: number; battle: boolean }; +// Module-scoped audio state — survives React provider remounts. +// MUDProvider swaps its internal component type when setupPromise resolves, +// which unmounts the entire SoundProvider subtree and would otherwise destroy +// Howls mid-load, leaving audio silent after a hard refresh. +const ambientHowls: Record = {}; +const battleHowls: Record = {}; +let activeTrack: TrackKey | null = null; +const missingZoneWarned: Set = new Set(); + +export const __resetSoundForTests = (): void => { + for (const key of Object.keys(ambientHowls)) delete ambientHowls[Number(key)]; + for (const key of Object.keys(battleHowls)) delete battleHowls[Number(key)]; + activeTrack = null; + missingZoneWarned.clear(); +}; + type SoundContextValue = { soundEnabled: boolean; toggleSound: () => void; @@ -49,15 +65,44 @@ export const useGameAudio = () => useContext(SoundContext); const keyEq = (a: TrackKey | null, b: TrackKey | null) => a !== null && b !== null && a.zone === b.zone && a.battle === b.battle; +const getHowl = (zoneId: number, battle: boolean): Howl | null => { + const map = battle ? BATTLE_AUDIO : ZONE_AUDIO; + const cache = battle ? battleHowls : ambientHowls; + let src = map[zoneId]; + let resolvedZone = zoneId; + + if (!src) { + const warnKey = `${battle ? 'battle' : 'ambient'}:${zoneId}`; + if (!missingZoneWarned.has(warnKey)) { + missingZoneWarned.add(warnKey); + console.warn( + `[SoundContext] No ${battle ? 'battle' : 'ambient'} track for zone ${zoneId}; falling back to zone 1`, + ); + } + // Fall back to zone 1 rather than going silent — any track beats none. + src = map[1]; + resolvedZone = 1; + if (!src) return null; + } + + if (!cache[resolvedZone]) { + cache[resolvedZone] = new Howl({ + src: [src], + loop: true, + volume: 0, + preload: true, + }); + } + return cache[resolvedZone]; +}; + export const SoundProvider = ({ children }: { children: React.ReactNode }): JSX.Element => { const { isAuthenticated } = useAuth(); const { currentZone } = useMap(); const { currentBattle } = useBattle(); const [soundEnabled, setSoundEnabled] = useState(() => { - const stored = localStorage.getItem(SOUND_ENABLED_KEY) === 'true'; - console.log('[SoundContext] mount — stored soundEnabled:', stored, 'zone:', currentZone, 'isAuth:', isAuthenticated); - return stored; + return localStorage.getItem(SOUND_ENABLED_KEY) === 'true'; }); // True while a fight is live OR while lingering after a fight just ended. @@ -65,67 +110,19 @@ export const SoundProvider = ({ children }: { children: React.ReactNode }): JSX. // when the player loads the page mid-combat. const [battleMode, setBattleMode] = useState(() => currentBattle !== null); - const ambientHowlsRef = useRef>({}); - const battleHowlsRef = useRef>({}); - const activeTrackRef = useRef(null); const lingerTimerRef = useRef | null>(null); const autoStartedRef = useRef(false); const inCombat = currentBattle !== null; - const missingZoneWarnedRef = useRef>(new Set()); - - const getHowl = useCallback((zoneId: number, battle: boolean): Howl | null => { - const map = battle ? BATTLE_AUDIO : ZONE_AUDIO; - const cache = battle ? battleHowlsRef.current : ambientHowlsRef.current; - let src = map[zoneId]; - let resolvedZone = zoneId; - - if (!src) { - const warnKey = `${battle ? 'battle' : 'ambient'}:${zoneId}`; - if (!missingZoneWarnedRef.current.has(warnKey)) { - missingZoneWarnedRef.current.add(warnKey); - console.warn( - `[SoundContext] No ${battle ? 'battle' : 'ambient'} track for zone ${zoneId}; falling back to zone 1`, - ); - } - // Fall back to zone 1 rather than going silent — any track beats none. - src = map[1]; - resolvedZone = 1; - if (!src) return null; - } - - if (!cache[resolvedZone]) { - console.log('[SoundContext] creating Howl', { src, zone: resolvedZone, battle }); - cache[resolvedZone] = new Howl({ - src: [src], - loop: true, - volume: 0, - preload: true, - onload: () => console.log('[SoundContext] Howl loaded', src), - onloaderror: (_id, err) => console.error('[SoundContext] Howl load error', src, err), - onplayerror: (_id, err) => console.error('[SoundContext] Howl play error', src, err), - onplay: () => console.log('[SoundContext] Howl playing', src), - }); - } - return cache[resolvedZone]; - }, []); - // Auto-enable sound when user authenticates (once per session). useEffect(() => { - console.log('[SoundContext] auto-enable check', { - isAuthenticated, - soundEnabled, - autoStarted: autoStartedRef.current, - sessionFlag: sessionStorage.getItem(SOUND_AUTO_STARTED_KEY), - }); if ( isAuthenticated && !soundEnabled && !autoStartedRef.current && sessionStorage.getItem(SOUND_AUTO_STARTED_KEY) !== '1' ) { - console.log('[SoundContext] auto-enabling sound'); autoStartedRef.current = true; sessionStorage.setItem(SOUND_AUTO_STARTED_KEY, '1'); setSoundEnabled(true); @@ -134,30 +131,20 @@ export const SoundProvider = ({ children }: { children: React.ReactNode }): JSX. }, [isAuthenticated, soundEnabled]); // Autoplay unlock: browsers suspend the audio context until a user gesture. - // Register listeners unconditionally when sound is enabled — Howler.ctx may - // not exist yet when this effect first runs (it's lazy-initialized on the - // first Howl construction), so we can't early-bail on ctx state. Howler has - // its own auto-unlock but in practice it doesn't always flush queued plays - // reliably, so we also nudge the active Howl back on. + // Howler has its own auto-unlock but in practice it doesn't always flush + // queued plays reliably, so we also nudge the active Howl back on. useEffect(() => { if (!soundEnabled) return; const unlock = () => { const ctx = Howler.ctx; - console.log('[SoundContext] unlock gesture', { ctxState: ctx?.state, active: activeTrackRef.current }); if (ctx && ctx.state !== 'running') { - ctx.resume().then(() => { - console.log('[SoundContext] ctx resumed, new state:', Howler.ctx?.state); - }).catch((err) => { - console.error('[SoundContext] ctx resume failed', err); - }); + ctx.resume().catch(() => {}); } - const active = activeTrackRef.current; - if (active) { - const cache = active.battle ? battleHowlsRef.current : ambientHowlsRef.current; - const howl = cache[active.zone]; + if (activeTrack) { + const cache = activeTrack.battle ? battleHowls : ambientHowls; + const howl = cache[activeTrack.zone]; if (howl && !howl.playing()) { - console.log('[SoundContext] unlock: nudging active Howl back on'); howl.play(); } } @@ -195,27 +182,19 @@ export const SoundProvider = ({ children }: { children: React.ReactNode }): JSX. // Main playback effect — crossfades between the correct track for the // (zone, battleMode) tuple. Picks crossfade duration based on transition type. useEffect(() => { - console.log('[SoundContext] playback effect', { - soundEnabled, - currentZone, - battleMode, - ctxState: Howler.ctx?.state, - active: activeTrackRef.current, - }); if (!soundEnabled) { - for (const howl of Object.values(ambientHowlsRef.current)) howl.stop(); - for (const howl of Object.values(battleHowlsRef.current)) howl.stop(); - activeTrackRef.current = null; + for (const howl of Object.values(ambientHowls)) howl.stop(); + for (const howl of Object.values(battleHowls)) howl.stop(); + activeTrack = null; return; } const desired: TrackKey = { zone: currentZone, battle: battleMode }; - if (keyEq(activeTrackRef.current, desired)) { - console.log('[SoundContext] playback effect — already on desired track, no-op'); + if (keyEq(activeTrack, desired)) { return; } - const prev = activeTrackRef.current; + const prev = activeTrack; // Pick crossfade timing from the transition type. let fadeMs = ZONE_CROSSFADE_MS; @@ -225,12 +204,12 @@ export const SoundProvider = ({ children }: { children: React.ReactNode }): JSX. } if (prev) { - const prevHowl = (prev.battle ? battleHowlsRef.current : ambientHowlsRef.current)[prev.zone]; + const prevHowl = (prev.battle ? battleHowls : ambientHowls)[prev.zone]; if (prevHowl) { const fromVol = prev.battle ? BATTLE_VOLUME : AMBIENT_VOLUME; prevHowl.fade(fromVol, 0, fadeMs); setTimeout(() => { - if (!keyEq(activeTrackRef.current, prev)) { + if (!keyEq(activeTrack, prev)) { prevHowl.stop(); } }, fadeMs + 50); @@ -240,29 +219,24 @@ export const SoundProvider = ({ children }: { children: React.ReactNode }): JSX. const nextHowl = getHowl(desired.zone, desired.battle); if (nextHowl) { const toVol = desired.battle ? BATTLE_VOLUME : AMBIENT_VOLUME; - console.log('[SoundContext] play()', { desired, fadeMs, toVol, ctxState: Howler.ctx?.state }); nextHowl.volume(0); nextHowl.play(); nextHowl.fade(0, toVol, fadeMs); - activeTrackRef.current = desired; + activeTrack = desired; } else { - console.warn('[SoundContext] no Howl returned for', desired); - activeTrackRef.current = desired; + activeTrack = desired; } - }, [soundEnabled, currentZone, battleMode, getHowl]); + }, [soundEnabled, currentZone, battleMode]); - // Cleanup on unmount. + // Cleanup on unmount — only the linger timer needs cleanup. The module-level + // Howl cache intentionally survives remounts so audio keeps playing through + // parent provider swaps. useEffect(() => { return () => { if (lingerTimerRef.current) { clearTimeout(lingerTimerRef.current); lingerTimerRef.current = null; } - for (const howl of Object.values(ambientHowlsRef.current)) howl.unload(); - for (const howl of Object.values(battleHowlsRef.current)) howl.unload(); - ambientHowlsRef.current = {}; - battleHowlsRef.current = {}; - activeTrackRef.current = null; }; }, []); From 6e932d169634278574346786e4f47c8ebc777ef8 Mon Sep 17 00:00:00 2001 From: Michael O'Rourke Date: Mon, 13 Apr 2026 15:59:35 -0600 Subject: [PATCH 2/2] chore(audio): re-add diagnostic logging for silent-playback investigation Module-scope cache fix shipped but audio is still silent on beta. Adding targeted logs to capture: - mount counter + module state at each mount (catches triple-mount edge cases) - keyEq no-op vs fresh-create branch of the playback effect - Howl onload / onloaderror / onplay / onplayerror callbacks - post-play snapshots at +500ms and +3s (playing?, volume?, ctxState?, Howler._muted?) - unlock gesture ctx resume with resolved state All 23 tests still pass; mock extended with Howler._muted and Howler.volume(). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../client/src/contexts/SoundContext.test.tsx | 2 + packages/client/src/contexts/SoundContext.tsx | 53 ++++++++++++++++++- 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/packages/client/src/contexts/SoundContext.test.tsx b/packages/client/src/contexts/SoundContext.test.tsx index 1d2edf85..6e85786e 100644 --- a/packages/client/src/contexts/SoundContext.test.tsx +++ b/packages/client/src/contexts/SoundContext.test.tsx @@ -23,6 +23,8 @@ vi.mock('howler', () => { playing: mockPlaying, })); const mockHowler = { + _muted: false, + volume: vi.fn().mockReturnValue(1), get ctx() { return { state: mockCtxState, resume: mockResume } as unknown as AudioContext; }, diff --git a/packages/client/src/contexts/SoundContext.tsx b/packages/client/src/contexts/SoundContext.tsx index 8daa21b9..65cde46a 100644 --- a/packages/client/src/contexts/SoundContext.tsx +++ b/packages/client/src/contexts/SoundContext.tsx @@ -42,6 +42,7 @@ const ambientHowls: Record = {}; const battleHowls: Record = {}; let activeTrack: TrackKey | null = null; const missingZoneWarned: Set = new Set(); +let mountCounter = 0; export const __resetSoundForTests = (): void => { for (const key of Object.keys(ambientHowls)) delete ambientHowls[Number(key)]; @@ -86,12 +87,19 @@ const getHowl = (zoneId: number, battle: boolean): Howl | null => { } if (!cache[resolvedZone]) { + console.log('[SoundContext] creating Howl', { src, zone: resolvedZone, battle, ctxState: Howler.ctx?.state }); cache[resolvedZone] = new Howl({ src: [src], loop: true, volume: 0, preload: true, + onload: () => console.log('[SoundContext] onload', src), + onloaderror: (_id, err) => console.error('[SoundContext] onloaderror', src, err), + onplay: () => console.log('[SoundContext] onplay', src, { vol: cache[resolvedZone].volume(), playing: cache[resolvedZone].playing(), ctxState: Howler.ctx?.state }), + onplayerror: (_id, err) => console.error('[SoundContext] onplayerror', src, err), }); + } else { + console.log('[SoundContext] reusing cached Howl', { src, zone: resolvedZone, battle }); } return cache[resolvedZone]; }; @@ -102,7 +110,18 @@ export const SoundProvider = ({ children }: { children: React.ReactNode }): JSX. const { currentBattle } = useBattle(); const [soundEnabled, setSoundEnabled] = useState(() => { - return localStorage.getItem(SOUND_ENABLED_KEY) === 'true'; + const stored = localStorage.getItem(SOUND_ENABLED_KEY) === 'true'; + mountCounter += 1; + console.log('[SoundContext] mount #' + mountCounter, { + stored, + zone: currentZone, + isAuth: isAuthenticated, + activeTrack, + ctxState: Howler.ctx?.state, + ambientCached: Object.keys(ambientHowls), + battleCached: Object.keys(battleHowls), + }); + return stored; }); // True while a fight is live OR while lingering after a fight just ended. @@ -138,13 +157,15 @@ export const SoundProvider = ({ children }: { children: React.ReactNode }): JSX. const unlock = () => { const ctx = Howler.ctx; + console.log('[SoundContext] unlock gesture', { ctxState: ctx?.state, activeTrack }); if (ctx && ctx.state !== 'running') { - ctx.resume().catch(() => {}); + ctx.resume().then(() => console.log('[SoundContext] ctx resumed →', Howler.ctx?.state)).catch((err) => console.error('[SoundContext] ctx.resume failed', err)); } if (activeTrack) { const cache = activeTrack.battle ? battleHowls : ambientHowls; const howl = cache[activeTrack.zone]; if (howl && !howl.playing()) { + console.log('[SoundContext] unlock: nudging Howl back on', { vol: howl.volume() }); howl.play(); } } @@ -182,7 +203,15 @@ export const SoundProvider = ({ children }: { children: React.ReactNode }): JSX. // Main playback effect — crossfades between the correct track for the // (zone, battleMode) tuple. Picks crossfade duration based on transition type. useEffect(() => { + console.log('[SoundContext] playback effect', { + soundEnabled, + currentZone, + battleMode, + activeTrack, + ctxState: Howler.ctx?.state, + }); if (!soundEnabled) { + console.log('[SoundContext] disabling — stopping all Howls'); for (const howl of Object.values(ambientHowls)) howl.stop(); for (const howl of Object.values(battleHowls)) howl.stop(); activeTrack = null; @@ -191,6 +220,7 @@ export const SoundProvider = ({ children }: { children: React.ReactNode }): JSX. const desired: TrackKey = { zone: currentZone, battle: battleMode }; if (keyEq(activeTrack, desired)) { + console.log('[SoundContext] keyEq match — no-op, current Howl continues'); return; } @@ -219,10 +249,29 @@ export const SoundProvider = ({ children }: { children: React.ReactNode }): JSX. const nextHowl = getHowl(desired.zone, desired.battle); if (nextHowl) { const toVol = desired.battle ? BATTLE_VOLUME : AMBIENT_VOLUME; + console.log('[SoundContext] play()', { desired, fadeMs, toVol, ctxState: Howler.ctx?.state }); nextHowl.volume(0); nextHowl.play(); nextHowl.fade(0, toVol, fadeMs); activeTrack = desired; + + // Post-play diagnostics — catches "Howler says playing but audio is silent". + setTimeout(() => { + console.log('[SoundContext] +500ms post-play', { + playing: nextHowl.playing(), + vol: nextHowl.volume(), + ctxState: Howler.ctx?.state, + howlerMuted: (Howler as unknown as { _muted: boolean })._muted, + howlerVolume: Howler.volume(), + }); + }, 500); + setTimeout(() => { + console.log('[SoundContext] +3000ms post-play', { + playing: nextHowl.playing(), + vol: nextHowl.volume(), + ctxState: Howler.ctx?.state, + }); + }, 3000); } else { activeTrack = desired; }