PURPOSE

Procedurally-synthesized sub-10 dB “ear-candy” SFX for every gameplay juice event. Owns a small library of short-lived Web Audio primitives (click / pop / crackle / thud / sizzle / sparkle / balloon-pop / laser), a recipe table that maps each event name to a sequence of primitive-plus-gain-plus-delay steps, the per-RAF SFX budget counter shared with weapon chimes, and a small handful of bespoke entries (XP tinkle, reward-card burst, medical-UI click namespace). No audio files — every sound is built from oscillators, biquads, and a shared one-second white-noise buffer.

OWNS

  • The shared _noiseBuffer — a one-second mono white-noise AudioBuffer lazily created on first crackle / sizzle / balloon-pop / xp-tinkle use and reused thereafter.
  • _lastXpTinkleTime — module-level timestamp used by the 100 ms XP-tinkle rate limiter.
  • _sfxThisFrame counter, _queue overflow array, and MAX_QUEUE cap (CFG.MAX_SFX_PER_RAF * 2). Exports bumpSfxFrame() so weapon-chimes.ts can share the same per-RAF budget.
  • UI_EVENTS — hardcoded Set of event names that should route through the UI SFX bus instead of the game SFX bus (menu clicks, stat stamps, level-up, every *_done event-completion cue, chase / warp / comet pickups, revive, credit error, pulse / vortex / cascade / forager / horde / comet-shower payoffs).
  • dB(db) linear-gain converter and the hFreq / hGain humanization helpers (±5% pitch, ±20% gain).
  • The eight sound primitives _click, _pop, _crackle, _thud (with optional heavy flag), _sizzle, _sparkle, _balloonPop, _laser — each builds its own oscillator(s) / noise source / filter / envelope graph, connects to the supplied bus, and self-terminates.
  • SoundStep tuple type, SoundRecipe array alias, and the RECIPES event-to-recipe table (≈120 entries spanning weapon fire, per-archetype death cues, per-prop break cues, per-artifact proc cues, shields, crates, props, collisions, XP / level-up, every *_done event payoff, hazards, wave-incoming telegraph, player death, warp-puddle transitions, enemy spawn, menu / stat feedback, medical-UI panel motions, credit error).
  • MicroSfx public export — enabled flag (seeded from ss_sound_muted in localStorage), resetFrameCount, play, _playXpTinkle, playCardBurst.
  • MedClick public export — click(flavor) namespaced helper that delegates to MicroSfx.play(${flavor}_click).

READS FROM

  • ./audio-contextAudioBus.getCtx, AudioBus.getGameSfxBus, AudioBus.getUiSfxBus, AudioBus.resume.
  • ../core/configCFG.MAX_SFX_PER_RAF for the per-frame budget and the overflow queue cap.
  • localStorage — reads ss_sound_muted once at module load to seed MicroSfx.enabled.
  • The browser’s performance.now() for XP-tinkle rate limiting and the Web Audio APIs (OscillatorNode, BufferSource, BiquadFilterNode, GainNode, StereoPannerNode) for graph construction.

PUSHES TO

  • Whichever bus AudioBus.getGameSfxBus() or AudioBus.getUiSfxBus() returns — every primitive’s final envelope GainNode connects to the supplied master argument, which the dispatcher picks via the UI_EVENTS set.
  • The shared _sfxThisFrame counter (incremented by bumpSfxFrame, zeroed by resetFrameCount).
  • The _queue overflow array — events that exceed the per-frame cap are pushed, then re-dispatched in the next resetFrameCount drain pass.
  • _lastXpTinkleTime is rewritten on each successful XP tinkle.
  • No localStorage writes, no telemetry, no signals.

DOES NOT

  • Does not load or decode any audio files. Every sound is synthesized live.
  • Does not decide which juice event fired — engine/effects/juice calls MicroSfx.play(event) and this module looks up the recipe.
  • Does not own the AudioContext, the master chain, the SFX / UI / music buses, or the mute state itself — those live in audio-context.ts (this module only reads the bus accessors and the cached mute flag).
  • Does not schedule its own per-RAF reset — bridge.ts calls resetFrameCount once per frame.
  • Does not drop over-budget events — they are queued (capped at MAX_QUEUE) and replayed next frame.
  • Does not spatialize sounds in world coordinates. The only stereo pan is the ±0.2 random panner in _playXpTinkle; every recipe is mono into a bus.
  • Does not pool or reuse oscillators / filter nodes. Each primitive allocates fresh nodes per play and lets osc.stop() plus GC clean up after the envelope finishes.
  • Does not toggle its own enabled flag at runtime — enabled is set once at module load from localStorage.
  • Does not branch by recipe shape in the dispatcher beyond the special-case for xp_pickup (which forks to _playXpTinkle).
  • Does not write the per-bus low-pass / reverb routing — those are baked into the bus chains in audio-context.ts; this module just connects to the bus input.

Signals

None. No BridgeSignals are emitted or subscribed to. All communication is direct function calls — Juice.fire calls MicroSfx.play, the bridge calls MicroSfx.resetFrameCount, the reward-card screen calls MicroSfx.playCardBurst, and medical-UI components call MedClick.click. weapon-chimes.ts reaches in for the shared bumpSfxFrame export rather than going through a signal.

Entry points

  • MicroSfx.play(event) — public dispatcher. Returns silently if enabled is false or the event has no recipe. Forks to _playXpTinkle() for xp_pickup. Calls bumpSfxFrame(); on overflow, queues the event (if _queue.length < MAX_QUEUE) and returns. Resolves the bus via UI_EVENTS.has(event), calls AudioBus.resume(), then walks each [fn, gainDb, delay, extra?] step calling fn(ctx, bus, dB(gainDb), delay, extra).
  • MicroSfx.resetFrameCount() — zeros _sfxThisFrame, snapshots _queue.length, splices that many events out, and replays each via this.play(...) (which counts against the new frame’s budget). Called once per RAF frame by bridge.ts.
  • MicroSfx._playXpTinkle() — special-cased XP pickup recipe. Rate-limited to once per 100 ms via _lastXpTinkleTime, still respects bumpSfxFrame(). Builds a C6 + C7 sine pair through a shared envelope, an allpass biquad with randomized frequency (400–1600 Hz) and Q (2–8), then a StereoPannerNode at ±0.2 random pan, falling back to a direct bus connection on older browsers.
  • MicroSfx.playCardBurst(isNew) — reward-card explosion. Routes through the UI bus (no LPF / no reverb). isNew=true plays a four-sparkle chord stagger (0 / 20 / 40 / 60 ms) plus a coin-land click at 80 ms and a metal sizzle at 90 ms; isNew=false plays a rising three-sparkle arpeggio (0 / 60 / 120 ms) plus a dust sizzle at 50 ms.
  • MicroSfx.enabled — public boolean, read at module load from localStorage['ss_sound_muted'].
  • MedClick.click(flavor='drawer') — namespaced helper. Calls MicroSfx.play(${flavor}_click), so it inherits the frame limiter, the mute flag, and the recipe-lookup miss-is-silent behavior.
  • bumpSfxFrame() — exported only so weapon-chimes.ts can share the per-RAF budget; not part of the MicroSfx object.

Pattern notes

  • Recipe table is the entire API surface. Adding a new event is a single object-literal entry in RECIPES. Removing one is a deletion. No registration function, no branching code in play() — the dispatcher walks whatever tuples it finds.
  • Per-frame budget is shared, not per-module. Both this file and weapon-chimes.ts call the same bumpSfxFrame(); the single counter _sfxThisFrame and the single resetFrameCount drain pass cover both. The bridge owns the once-per-RAF reset.
  • Overflow is queued, not dropped. Excess events go into _queue (capped at 2 * CFG.MAX_SFX_PER_RAF); the next resetFrameCount snapshots the length and replays exactly that batch, so events that overflow again during the drain end up at the new tail rather than being re-attempted in a tight loop.
  • UI vs game routing by hardcoded set. UI_EVENTS is a literal Set<string>. The same recipe can sound different depending on whether its event name is in the set: game-bus events pass through the 3 kHz LPF + reverb in audio-context.ts; UI-bus events skip both for a dryer, brighter payoff.
  • dB-coded recipes. Every recipe gain is expressed in dB ranging roughly from −8 (loudest payoff cues) to −28 (XP pickup fallback). The dispatcher converts via dB(db) = 10^(db/20) so designers tune in familiar units.
  • Humanization is centralized. Every primitive runs its base frequency through hFreq and its peak gain through hGain to add ±5% pitch and ±20% volume jitter for organic variation. There is no “deterministic” path through a primitive.
  • Shared noise buffer. Crackle, sizzle, balloon-pop, and the XP tinkle’s allpass-coloured path all reuse a single one-second mono white-noise AudioBuffer; each primitive picks a random loopStart / loopEnd window so the underlying samples look different even though the buffer is shared.
  • Per-archetype / per-prop / per-artifact override maps. The recipe table includes dispatch-site override entries (enemy_kill_charger, prop_break_volatile_crystal, artifact_devourer, …). The caller — damage.ts, props.ts, actions.ts:actFlashArtifact — looks up the override key first and falls back to the generic recipe (enemy_kill, prop_break, etc.) when missing.
  • xp_pickup is the only event the dispatcher special-cases. Its recipe entry is a fallback placeholder; play() checks for the event name before walking steps and forks to _playXpTinkle(), which has its own rate limiter, allpass colouring, and stereo pan.
  • Self-terminating envelopes. Every primitive sets osc.start(now) + osc.stop(now + dur + tail) and connects through a per-call envelope GainNode. Nodes disconnect themselves when the source ends and are garbage-collected — no manual pool, no cleanup queue.
  • Mute is read-only at module load. MicroSfx.enabled is initialized once from localStorage['ss_sound_muted'] and not re-checked; runtime mute toggling has to flip enabled directly (audio-context owns the user-visible volume sliders).
  • MedClick is an indirection, not a separate engine. It exists so future medical-UI variants (e.g. panel_click, tab_click) can be added without changing call sites — every variant still goes through MicroSfx.play and inherits the frame limiter + mute behavior.