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