PURPOSE
Owns the single shared AudioContext for the game and the three-bus mixing architecture (game SFX, UI SFX, music) routed through a master output chain (glue compressor → brick-wall limiter → dry + master reverb + stereo chorus). All sound-producing code connects into one of the bus inputs exposed by the AudioBus singleton. Lazy-initializes on first getCtx() call so the AudioContext is only created after a user gesture (browser autoplay policy).
OWNS
- The singleton
AudioContext(_ctx) and every Web Audio node hanging off it. - The master output chain:
_masterGain,_masterComp(glue, 2:1, −18 dB threshold),_masterLimiter(brick wall, 20:1, −1 dB threshold),_preMaster, master reverb send/return/convolver, and master stereo chorus (two delay lines modulated by a 0.5 Hz LFO). - The synthetic 0.6-second small-room impulse response generated by
_createRoomIR(five early reflection taps plus exponential-decay diffuse tail; shared between master reverb and music reverb convolvers). - The three sub-bus input nodes:
_gameSfxGain,_uiSfxGain,_musicGain. Each has its own per-bus chain feeding_masterGain. - The music-specific environment reverb chain:
_musicReverbSend,_musicConvolver,_musicReverbReturn. - The music bus low-pass filter (
_musicLpf) and its parallel wet/dry blend nodes (_musicLpfWetGain,_musicDryGain). - Volume state:
_volume(master),_sfxVolume(shared SFX),_musicVolume(music slider),_musicDepth(environment depth). - The
MUSIC_VOLUME_SCALEconstant (0.7) applied to all music slider values. - The persisted localStorage keys
ss_sound_muted,ss_sfx_volume, andss_music_volume.
READS FROM
window.AudioContext(withwebkitAudioContextfallback) for context construction.localStoragefor persisted mute and per-bus volume defaults on module load and to write updated SFX/music volumes.- Caller-supplied values to
setVolume,setSfxVolume,setMusicVolume,setMusicEnvironment, andsetMusicLowpass.
PUSHES TO
- Web Audio
_ctx.destinationvia three parallel paths from_preMaster: dry (through_preMaster), master reverb return, and master stereo chorus return. _ctx.destinationseparately via_musicReverbReturnfor the music-only environment reverb.localStoragekeysss_sfx_volumeandss_music_volumeon volume setter calls.- Returns
GainNodereferences fromgetGameSfxBus,getUiSfxBus,getMusicBus, andgetMasterfor consumers to connect their own sources. - Returns the music
BiquadFilterNodefromgetMusicLowpassso callers can directly automate the cutoff (used by the warp-puddle audio tween).
DOES NOT
- Does not load or decode any audio files; the only sound buffer it generates is the synthetic impulse response.
- Does not play any sound itself — it only builds the routing graph and exposes bus inputs.
- Does not subscribe to game state, ticks, or the bridge; all updates are pull-based via setter calls from consumers.
- Does not handle mute toggling beyond reading the persisted
ss_sound_mutedflag once on load to set the initial_volumeto 0; there is nosetMutedAPI. - Does not recreate or rewire nodes after
dispose()automatically — the nextgetCtx()rebuilds from scratch. - Does not apply per-source effects (panning, pitch, envelopes); that is the consumer’s responsibility.
Signals
- Lazy init signal: the first call to
getCtx()(or anyget*Bus()method) triggers_init(), which constructs the entire graph and starts the chorus LFO oscillator. - Volume changes propagate immediately by writing to the
gain.valueof the relevant nodes. setMusicEnvironment(depth)adjusts_musicReverbSend.gainlinearly (0–20% wet) and calls_applyMusicGainto compensate music volume downward by up to 15% at full depth.setMusicLowpass(freq)schedules an exponential ramp on_musicLpf.frequencyover 0.3 seconds.- The chorus LFO runs continuously from init until
dispose()is called.
Entry points
AudioBus.getCtx(): AudioContext | null— returns the shared context, lazy-initializing.AudioBus.getMaster(): GainNode | null— backward-compat alias returning the game SFX bus input.AudioBus.getGameSfxBus(): GainNode | null— bus input for gameplay SFX (weapon chimes, micro-sfx).AudioBus.getUiSfxBus(): GainNode | null— bus input for UI sounds (menu clicks, reward stamps).AudioBus.getMusicBus(): GainNode | null— bus input for music tracks.AudioBus.getMusicLowpass(): BiquadFilterNode | null— direct handle to the music LPF for the warp-puddle tween.AudioBus.resume(): void— resumes the context if suspended (call from any user gesture).AudioBus.setVolume(v) / getVolume()— master volume (also applied to reverb and chorus returns).AudioBus.setSfxVolume(v) / getSfxVolume()— shared volume for game and UI SFX buses (persisted).AudioBus.setMusicVolume(v) / getMusicVolume()— music slider value before scale/compensation (persisted).AudioBus.setMusicEnvironment(depth: 0..1)— sets music reverb wetness and compensating gain reduction.AudioBus.setMusicLowpass(freq)— exponential ramp to the given cutoff frequency over 0.3 s.AudioBus.dispose()— stops the chorus LFO, closes the context, and nulls every node reference.
Pattern notes
- Singleton module-scoped state: every node is held in a private
let _foo: T | null = nulland accessed only through theAudioBusobject literal. The module is its own instance — there is no class. - Lazy init guarded by
if (!_ctx) _init()at the top of every public getter, so the order of first call does not matter. - Per-bus chains all merge into a single
_masterGainso the master glue compressor and limiter see the summed mix, not individual buses. - Master reverb and master chorus are wired as parallel sends from
_preMaster, additive to the dry path rather than replacing it. - Music bus uses a parallel dry/wet blend around
_musicLpf(separate_musicDryGainand_musicLpfWetGainnodes feeding the same compressor) rather than a single in-line filter, so the wet/dry ratio is independently controllable. - The synthetic impulse response is generated in pure JS at init via
_createRoomIR; no IR file ship as an asset. Stereo decorrelation comes from per-channel random noise and ±1 ms tap jitter. - Chorus uses two
DelayNodes with opposite-polarity LFO modulation (+0.002and−0.002gain on the LFO send) so the stereo image breathes rather than moving in unison. - Volume setters clamp to
[0, 1]withMath.max(0, Math.min(1, v)). - localStorage reads and writes are wrapped in
try/catchso the module works under SSR or when storage is unavailable. - Backwards-compat shim:
getMaster()returns the game SFX bus input so legacy callers that never migrated to a specific bus still produce sound. dispose()is idempotent-safe: everytry { _chorusLfo.stop() } catch {}and_ctx.close().catch(() => {})swallows already-stopped errors.