AudioContext Resume Gesture Handling
Browser autoplay policies suspend any AudioContext created before a user gesture. The AudioBus module in engine/audio/audio-context.ts handles this with a combination of lazy initialization and an explicit resume() entry point wired to the first user input.
Why it matters
Every modern browser ships an autoplay policy: a fresh AudioContext starts in the suspended state and produces no sound until a user gesture (tap, click, keypress) un-suspends it. Calling ctx.resume() outside a gesture handler does nothing on iOS Safari and is no-ops in Chrome/Firefox until the user has interacted with the page at least once.
Starship Survivors is a mobile-web game with audio-driven feedback (weapon chimes, UI clicks, music). If audio is silent for the first few seconds of play, the game feels broken.
Lazy init
AudioBus.getCtx() defers construction until the first caller actually needs the context:
- Module load creates no
AudioContext— only singleton slots (_ctx,_masterGain, bus inputs) are declared asnull. - The first call to
getCtx()(or any bus getter likegetGameSfxBus(),getUiSfxBus(),getMusicBus()) runs_init(), which constructs the context and wires the full master chain. _init()catches construction failures and returnsnullso the caller can no-op gracefully on environments without Web Audio.
This means the context is created on the same call stack as the gesture that triggers the first sound, maximizing the chance the browser accepts it as user-initiated.
resume() entry point
AudioBus.resume() is the canonical un-suspend hook:
resume(): void {
if (_ctx && _ctx.state === 'suspended') {
_ctx.resume().catch(() => {});
}
}
Callers wire it to the first tap or keypress in the app. The check is idempotent — if the context is already running (or hasn’t been constructed yet) the call is a no-op. The .catch(() => {}) swallows rejections so the gesture handler never throws.
Persisted unlock state
Once the user has interacted with the page and resume() succeeds, the AudioContext state moves to running and stays there for the lifetime of the tab:
- No re-locking on subsequent navigations within the SPA.
- No re-locking when the tab loses focus and regains it.
- The context only goes away when the tab is closed or
AudioBus.dispose()is called explicitly.
Subsequent calls to resume() are cheap no-ops because _ctx.state is no longer 'suspended'.
Failure modes
- SSR / no
window.AudioContext—_init()returnsnull; all bus getters returnnull; consumers must null-check (and do). resume()called before_init()— guarded byif (_ctx && ...); harmless no-op.resume()called without a real gesture — promise rejects silently; user can tap again to retry.
Related
engine/audio/audio-context.ts— implementationengine/audio/weapon-chimes.ts,micro-sfx.ts,music-player.ts— consumers that pull bus inputs throughAudioBus.getCtx()