PURPOSE

Plays decoded WAV/OGG samples for key gameplay moments — UI launch, mission-complete, energy-weapon layer, player damage feedback, reward stingers, and the heat-driven thrust crossfade loop. Owns the lazy fetch/decode pipeline, the thrust voice graph (sub-bus + LPF + compressor), the reward stinger parallel LPF sub-bus, and an out-of-tune oscillator warning beep that fires above 96% heat.

OWNS

  • SAMPLE_PATHS registry mapping sample names to /sounds/*.wav and *.ogg files served from public/sounds/.
  • _buffers cache (Map<string, AudioBuffer>) and the _loadingStarted guard for one-shot async fetch + decodeAudioData.
  • Thrust sub-bus graph: _thrustBus (GainNode) → _thrustLpf (lowpass 1800 Hz, Q 0.707) → _thrustCompressor (threshold -23 dB, knee 3, ratio 12, attack 0.003 s, release 0.15 s) → game SFX bus.
  • Reward sub-bus graph: _rewardBus split into 50% dry path and 50% wet path through a 4 kHz lowpass (Q 0.707), both summed into the UI bus.
  • Thrust voice pool: _voices: ThrustVoice[] (each holds source, gain, startTime, basePitch); per-hold counters _voiceCount, _nextHeatThreshold, and the _thrustActive flag.
  • Low rumble layer state: _thrustLowSource (looping AudioBufferSourceNode) and _thrustLowGain with an attack/decay/sustain/release envelope.
  • Warning-beep cadence state _lastWarnBeep (AudioContext-time timestamp).
  • Internal helpers _ensureLoaded, _playOneShot, _ensureRewardBus, _ensureThrustBus, _spawnVoice, _tailOffVoice, _killVoice, _playWarnBeep, _crossfade.
  • Public SampleSfx singleton with the player-facing API.

READS FROM

  • AudioBus (./audio-context) — getCtx() for the shared AudioContext, getUiSfxBus() for UI-routed samples, getGameSfxBus() for thrust/energy layer, and resume() to unlock on first user-gesture-driven play.
  • public/sounds/ HTTP assets — each entry in SAMPLE_PATHS is fetched via fetch() and decoded with ctx.decodeAudioData. Failed entries leave the buffer absent; callers no-op when the buffer is missing.
  • Caller-supplied gameplay state — updateThrust(isThrusting, heat), playShieldHit(absorbed, shieldMax), playHullHit(hullDmg, hpMax) use those values for envelope and gain scaling.

PUSHES TO

  • Web Audio graph — connects sources/gains/filters/compressors into the appropriate AudioBus outputs. Output never bypasses an AudioBus mixer node.
  • console.warn once if _ensureLoaded decoded fewer samples than were requested.

DOES NOT

  • Does not own or mutate the master/UI/game/music GainNodes; those belong to AudioBus.
  • Does not stream or schedule music — that is the music-engine module’s responsibility.
  • Does not read game-state stores directly; all gameplay inputs arrive as method arguments.
  • Does not retry failed sample fetches — _loadingStarted latches true even when some samples fail.
  • Does not run a per-frame ticker; consumers must call updateThrust themselves each frame for the heat crossfade loop to advance.
  • Does not despawn voices on stopAll other than by calling source.stop() and disconnecting gain — the onended handler still removes them from _voices.

Signals

  • THRUST_BASE_DB = -34, GAIN_STEP_DB = 0.5 per successive voice copy.
  • THRUST_LOW_DB = -26, sustain = 50% of peak; attack 0.2 s, decay 0.4 s, release 1.0 s.
  • THRUST_FADE_OUT = 0.3 s on release of the heat-driven voices.
  • CROSSFADE_OUT = 2.0 s exponential ramp on tail-off; TIME_CROSSFADE_LEAD = 4.0 s before sample end triggers a fallback crossfade.
  • HEAT_STEP = 20 — heat delta between successive crossfades (the next threshold is heat + HEAT_STEP at thrust start). Header doc references “every 10%” but the constant is 20.
  • PITCH_STEP = 0.05 per voice copy; PITCH_CAP = 1.5.
  • Warning beep — WARN_FREQ = 566 Hz square with detune = 15 cents, WARN_GAIN = dB(-28), WARN_BEEP_DUR = 0.06 s, beep interval lerps from WARN_INTERVAL_MAX = 0.35 s at 96% heat to WARN_INTERVAL_MIN = 0.12 s at 100%, starts at WARN_HEAT_START = 96.
  • Reward sub-bus — REWARD_LPF_HZ = 4000, REWARD_WETNESS = 0.5.
  • One-shot gains — launch: -10 dB with 0.2 s fade-in; mission complete: -16 dB; energy layer: -25 dB; shield hit: dB(-25) * (0.25 + 0.75 * frac); hull hit: dB(-25) * (0.35 + 0.65 * frac); all reward stingers and gong: -20 dB.
  • Voice cap of 2 — _crossfade hard-kills the oldest and tail-offs the second when at the cap.

Entry points

  • SampleSfx.preload() — fires _ensureLoaded asynchronously without awaiting.
  • SampleSfx.playLaunch() — UI bus, -10 dB with 0.2 s exponential fade-in; calls AudioBus.resume().
  • SampleSfx.playMissionComplete() — UI bus one-shot at -16 dB; calls AudioBus.resume().
  • SampleSfx.updateThrust(isThrusting, heat) — per-frame; starts/stops the heat-driven voice pool and the low rumble loop, triggers heat-step and time-based crossfades, respawns if all voices ended between frames, and emits warning beeps once heat >= 96.
  • SampleSfx.playEnergyLayer() — game SFX bus; picks energy_1..3 uniformly at random and plays at -25 dB.
  • SampleSfx.playShieldHit(absorbed, shieldMax) — UI bus; volume scales by absorbed fraction of max shield (floor 0.25, ceiling 1.0).
  • SampleSfx.playHullHit(hullDmg, hpMax) — UI bus; random player_hull_1..4; volume scales by HP-loss fraction (floor 0.35).
  • SampleSfx.playArtifactReveal(), playWeaponChestOpen(), playLevelUp(), playLevelUpClick(), playReward(), playGong() — all route through the reward sub-bus at -20 dB.
  • SampleSfx.stopAll() — clears the thrust active flag, stops every active voice and the low rumble source, disconnects their gains.

Pattern notes

  • Lazy buffer loading runs once per process. _loadingStarted is a latch — re-calling preload() after a fetch failure will not retry.
  • All _playOneShot and playLaunch paths fail silently when the AudioContext, target bus, or required buffer is missing; this is the intended boundary behaviour (audio is non-critical).
  • Sub-buses are created lazily inside the first call that needs them (_ensureThrustBus, _ensureRewardBus) and cached for the lifetime of the process.
  • Thrust voice lifecycle uses both an onended handler (removes from _voices, disconnects gain) and explicit source.stop() on release; gain envelopes use exponentialRampToValueAtTime and therefore always target 0.0001 rather than 0.
  • The thrust voice cap relies on _crossfade: at length 2 the oldest is hard-killed, then the next-oldest tail-offs, then a new voice is spawned — meaning during a crossfade only two voices share the bus.
  • The low rumble layer (thrust_low) is independent of the heat-step crossfade — it is a single looped source with its own ADSR envelope and rides the same thrust sub-bus.
  • The warning beep deliberately bypasses the game SFX low-pass by routing through the UI bus, and its 15-cent detune is intentional for an unsettling feel.
  • Header comment describes “every 10% heat” but the live constant HEAT_STEP = 20 and inline comment (“Every 20% heat gained”) are the source of truth.
  • _voiceCount and _nextHeatThreshold are reset only on the start-of-thrust edge; they are not reset between consecutive holds inside a single mission.
  • Module state is module-singleton — there is no per-instance/per-scene reset other than stopAll() and the start-of-thrust edge logic.