PURPOSE

Generates per-shot weapon “chime” audio so a four-weapon loadout sounds like a harmonic chord rather than a noise stack. Each weapon slot is locked to a scale degree of a C major 7th voicing (C / E / G / B), and the weapon family supplies a timbre (oscillator type, envelope, filter, octave shift). A sci-fi laser sweep is layered under every shot, and energy-tagged weapons also trigger a sampled energy layer. Also hosts the dash-related one-shots used by the player traversal system (activation chime, whoosh, pop, error buzz).

OWNS

  • SLOT_FREQUENCIES — C4 / E4 / G4 / B4 pitch map keyed by slot index (0–3).
  • ChimeTimbre interface and the TIMBRES table (glass, bronze, crystal, sine, tine, pluck, bell, impact) plus DEFAULT_TIMBRE fallback.
  • Humanization helpers humanizeFreq (±~3% pitch jitter via ± 0.06 / 2) and humanizeGain (±15% volume jitter).
  • _playLaserLayer — sawtooth sweep from a randomized 1200–1800 Hz down to 200–400 Hz, low-passed at 3000 Hz, mixed at LASER_GAIN_MULT = 0.316 (~-10 dB) of the chime gain.
  • WeaponChimes module: play, setVolume, dispose, playDashChime, playDashWhoosh, playDashPop, playDashError.

READS FROM

  • ./audio-contextAudioBus.getCtx(), AudioBus.getGameSfxBus(), AudioBus.resume(), AudioBus.setVolume(), AudioBus.dispose().
  • ./micro-sfxbumpSfxFrame() gate that throttles per-frame SFX spam; chime exits early if the gate denies.
  • ./sample-sfxSampleSfx.playEnergyLayer() when damageTag === 'energy'.
  • Caller-supplied slotIndex, timbreKey, and optional damageTag (timbre key originates from WeaponCoreSpec.chimeTimbre in weapon data).

PUSHES TO

  • Web Audio graph rooted at the shared game SFX bus GainNode returned by AudioBus.getGameSfxBus(). All oscillators, gain envelopes, biquad filters, and the dash whoosh noise buffer connect into that bus.
  • No game-state writes, no store dispatches, no DOM, no telemetry.

DOES NOT

  • Does not decide when weapons fire; it only renders audio when called.
  • Does not own the audio context lifecycle — it delegates create/resume/dispose to AudioBus.
  • Does not throttle itself across frames beyond the single bumpSfxFrame() gate at the top of play; the dash helpers are not gated.
  • Does not adjust mixer levels per weapon beyond the timbre’s peakGain and the humanization jitter.
  • Does not persist anything, does not load samples (the energy sample lives in sample-sfx).
  • Does not cancel or pool oscillators — each shot allocates fresh OscillatorNode, GainNode, and BiquadFilterNode instances and schedules stop() shortly after the envelope ends.

Signals

  • Input call signal: WeaponChimes.play(slotIndex, timbreKey, damageTag?) from the weapons firing path.
  • Gate signal: bumpSfxFrame() returns false → chime is skipped entirely (energy layer is also skipped because the early return runs first).
  • Resume signal: every entry point calls AudioBus.resume() to satisfy browser autoplay policies before scheduling.
  • Audio output signal: oscillator + filter + envelope graph terminating at the shared SFX bus, where the main mix routes it to the destination.
  • Stop signal: playDashWhoosh returns a callback that cancels the envelope and stops the noise source early (used when a dash ends prematurely, e.g. terrain collision).

Entry points

  • WeaponChimes.play(slotIndex: number, timbreKey: string, damageTag?: string): void — main per-shot entry. Picks pitch from SLOT_FREQUENCIES (clamped to last slot), picks timbre from TIMBRES (falls back to DEFAULT_TIMBRE = TIMBRES.glass), schedules a primary oscillator through a highpass + envelope, optionally a detuned second oscillator at half gain (no filter), and always layers the sci-fi laser sweep.
  • WeaponChimes.setVolume(v: number): void — proxies to AudioBus.setVolume.
  • WeaponChimes.dispose(): void — proxies to AudioBus.dispose.
  • WeaponChimes.playDashChime(): void — two-note C5 + G5 (perfect fifth) triangle+sine chorus with 1.8 s decay, second note staggered 15 ms.
  • WeaponChimes.playDashWhoosh(): (() => void) | null — looped white-noise buffer through a bandpass sweep (200 → 3000 → 800 Hz over 0.8 s) with a swell envelope; returns a stop function or null if the audio context isn’t available.
  • WeaponChimes.playDashPop(): void — 300 → 80 Hz sine thud plus 2000 Hz triangle click.
  • WeaponChimes.playDashError(): void — two detuned square waves at 150 Hz and 158 Hz (~90 cents apart) for a dissonant buzz.

Pattern notes

  • Slot-to-degree mapping is fixed at module scope (SLOT_FREQUENCIES); changing the chord voicing means editing this constant. Math.min(slotIndex, SLOT_FREQUENCIES.length - 1) means any out-of-range slot collapses onto the B4 seventh.
  • Timbre lookup is a string keyed table; unknown timbreKey values silently fall back to glass. New weapon families add a TIMBRES entry plus a matching chimeTimbre in weapon data.
  • Per-shot timing is humanized only via humanizeFreq (pitch jitter) and humanizeGain (volume jitter); there is no explicit phase or scheduling offset — the “wind chimes / polyrhythm” feel relies on weapon fire cadence varying naturally.
  • The detuned second oscillator is gated on timbre.detuneCents > 0 and bypasses the highpass filter, mixing in at half the primary’s peak gain.
  • The laser layer is unconditional inside play and shares the parent chime’s attack and decay so it tracks the chime envelope rather than running on its own timeline.
  • Energy-tag layering is independent of timbre and runs SampleSfx.playEnergyLayer() once per qualifying shot, after the bumpSfxFrame gate.
  • Oscillators self-terminate via osc.stop(now + attack + decay + 0.05) — there is no manual cleanup, the browser GCs disconnected nodes once they finish playing.
  • Dash helpers (playDashChime, playDashWhoosh, playDashPop, playDashError) live in this file because they share the same Web Audio scaffolding and SFX bus; they are not gated by bumpSfxFrame and are intended to be invoked sparingly from the dash code path.
  • All numeric constants (slot frequencies, timbre fields, laser sweep range, dash decay, error detune) are declared as named module constants or table fields rather than magic numbers inside functions.