Sound Pass — the category mixer + five audible improvements

2026-06-11 · lib/audio · all sound 100% procedural (zero binary assets) · oracle: scripts/audio-render.ts (offline render of the REAL synth graph) · every number below is from the seed-pinned oracle, before = HEAD, after = this pass

What this pass did

Two deliverables. One: a per-category sound mixer — combat, ambience, radio, and alerts each get an on/off toggle and a volume slider, persisted per device. Two: five improvements to the audio itself, each grounded in either a recorded residual of the 2026-06-07 soundscape campaign or a missing battlefield signature — and each verified by the offline oracle as a number, not a vibe.

1 · The sound mixer

The engine already had four submix buses; the mixer inserts a user gain between each bus and master (bus → category → master, lib/audio/player.ts). That placement is the design decision: the contact-duck system writes absolute values to the bus gains, so putting the user's trim on a separate downstream node means ducking and user preference multiply instead of fighting. Two consequences worth knowing: the shared valley-reverb return is routed into the combat category (only combat sounds feed it — muting combat must also mute its echo, not leave a disembodied tail), and the danger-close klaxon moved from the combat bus to the alerts bus where it semantically belongs (it is a command-channel warning, not battlefield sound) with trims re-balanced so its absolute level is unchanged.

The sound mixer popover: per-category toggle + slider

Live-verified end to end: UI toggle → store → engine gain (ambience 0.000, combat 0.500) → localStorage itm-ui-v1 → survives reload. The oracle re-render after the re-routing showed all 24 scenes within 0.15 dB of baseline — the mixer's plumbing is level-neutral by construction.

2 · The five audio improvements

2.1 · The incoming-shell whistle (new battlefield signature)

Why: indirect fire had a tube report ("shot") and an impact ("splash") but nothing in between — yet the 2–3 seconds of descending shriek before a mortar round lands is the single most recognizable indirect-fire experience in every first-hand account. The sim already counts down FireMission.etaS each tick, so the mapper now voices one whistle per mission, deterministically, 2.4 s before the first round (INCOMING_LEAD_S, lib/audio/mapper.ts) — the whistle ends exactly as the blast arrives. The synth is a triangle sweep (~1.3 kHz → 320 Hz) with a flutter that deepens as the round closes, plus a swept-noise whoosh an octave up, under a swelling envelope: the swell is the dread.

audible: — → 2260 mscorr: — → 0.3rms: — → -29.5 dB
incoming — the shell whistle, solo2.26 s of descending whistle; in the game it always ends in a blast
before
— did not exist —
after
Spectrogram: the descending incoming whistle
the full firefight scenelisten at ~8 s: shot → whistle → splash → danger-close klaxon, inside a sustained TIC
before
after

2.2 · Calm-bed stereo width (the recorded residual)

Why: the 2026-06-07 campaign shipped the living valley bed but recorded its own gap: the calm scene measured corr 0.997 / width 4% — effectively mono. The fix was less about panning and more about a subtlety of synthesis: all bed voices loop one shared noise buffer, and two in-phase filters of the same source stay correlated no matter how you pan them. Each voice now starts at a different offset into the loop (noise autocorrelation dies in samples, so offset reads are genuinely uncorrelated), and then the panning does real work: the river splits into two band voices (950 / 1900 Hz, the bird-band notch preserved) straddling the river's actual screen bearing, the generator pans to the COP's bearing, rain is Haas-widened (13 ms), and the wind howl spreads ±0.5.

width: 4% → 12.4%corr: 0.997 → 0.963rms: -42.62 dB → -45.48 dB
ambient-calm — a clear day at the COPwind, river, generator, birds; the width is the change — use headphones
before
after
ambient-night — katabatic wind, crickets, the lone dogsame width mechanism on the night bed
before
after
Spectrogram A/B: ambient-calm before and after

2.3 · Thunder (new — the storm finally sounds like one)

Why: Rain weather had a hiss bed and droplet ticks but no thunder — a Kunar mountain storm without its defining sound. A new Poisson spot layer (λ = 0.022/s, Rain only) builds each roll as a mid-frequency strike head that darkens into a brown-noise rumble with 2–3 swells, answered ~0.6 s later by a quieter roll panned to the opposite quarter — the sound crossing the valley. Deliberately not gated by contact: a firefight silences the birds, never the sky.

rms: — → -40.91 dBwidth: — → 20.9%tail: — → 12490 ms
ambient-storm — rain bed + a thunder rollthe roll arrives at ~1.2 s from the left, answers from the right
before
— did not exist —
after
Spectrogram: the storm scene

2.4 · The adhan gets a voice

Why: the call to prayer is formant-filtered sawtooth with a programmed melisma — but a held note with mathematically flat pitch reads as a slide whistle, not a human voice. A 5.3 Hz vibrato (depth 2.5% of the base pitch) fading in over the first second is the classic synthesis tell of a singing voice, and it does more for realism than any filter change.

rms: — → -38.52 dBcentroid: — → 2569 Hzaudible: — → 10000 ms
the adhan from the village (dusk, panned east)a new oracle scene — the baseline render had no adhan sample to compare
before
— did not exist —
after
Spectrogram: the adhan with vibrato

2.5 · Ricochet variety

Why: every deflection played the identical zing — the most repetitive sound in a long firefight. Three deterministic timbre families now key off the cue's hash (cue.v): the classic singing zing (~45% of rounds), a short hard fast-tumbling buzz (~30%), and a rarer high thin whine with a long fall (~25%) — plus an impact tick grounding each one in a hit. Same mechanism, three parameterizations; same seed still gives the same firefight.

audible: 720 ms → 580 mscentroid: 2070 Hz → 2508 Hz
ricochet (the v=0.5 family — the new buzz)the palette renders one family; all three appear in the firefight scene
before
after
Spectrogram A/B: ricochet before and after

3 · The numbers, all in one table

scenerms dBcorrwidth %tail msaudible ms
ambient-calm-42.62 → -45.480.997 → 0.9634 → 12.41740 → 1027010500 → 10500
ambient-night-42.42 → -45.060.995 → 0.9565 → 13.310480 → 1048010500 → 10500
ambient-storm— → -40.91— → 0.873— → 20.9— → 12490— → 12500
spot-adhan— → -38.52— → 0.92— → 27.3— → 9980— → 10000
palette-incoming— → -29.5— → 0.3— → 42.3— → 1070— → 2260
palette-ricochet-41.22 → -42.610.539 → 0.57135.4 → 34.3800 → 670720 → 580
firefight-27.02 → -26.740.496 → 0.4836.7 → 37.27100 → 709018140 → 18140
distant-35.51 → -35.510.056 → 0.05648.6 → 48.64460 → 44608200 → 8200
occlusion-ridge-40.34 → -40.340.685 → 0.68532.3 → 32.31890 → 18904920 → 4920

Oracle assertions after this pass — all green: no clipping (busiest scene −2.8 dBFS) · calm→combat dynamic spread 25.8 dB (was 23.0) · ambient bed alive (−45.5 dB) · firefight corr 0.48 · occlusion 6.6 dB / HF 30.8% vs 40.9% · calm width 12.4% (was 4%) · storm alive · whistle sustains 2.26 s. Plus scripts/audio-probe.ts green (mapper purity, 1:1 cue mapping, determinism, layer law) — incoming now part of its coverage set.

4 · Honest residuals

5 · How to verify this yourself