Public: anyone can read this; the crew can change it.
forked from trevato/cs
A firefight does not crackle: one output bus with measured headroom, and a limiter on it #276
Check · Review · Land · Live done — Landed and live in production.
The check passes locally (typecheck + 1,203 tests, run four times clean) and the branch is pushed.
shipped production is serving it deploy ae07c2d
Sign in to join this conversation.
new above
offline
The check passes locally (typecheck + 1,203 tests, run four times clean) and the branch is pushed.
---
Every voice the engine's sound device starts now ends at one master
GainNodemade with the context, and that bus is the only thing reachingcontext.destination— through aDynamicsCompressorNodeused as a limiter. Nothing connects to the destination directly any more.The headroom is arithmetic.
LOUDEST_INSTANTinsrc/client/audio.tsis the loudest thing these recipes can ask for — a grenade landing in a five-man crossfire, every voice scheduled at the samecurrentTimebecause a frame plays everything since the last frame at once:5 × 0.65 + 1.25 + 0.85 + 0.58 = 5.93, nearly six times the 1.0 a sample holds.MASTER_GAINis 0.6, chosen from the other end: one gunshot alone was 0.65 and is now 0.39, under the limiter's −4 dBFS threshold (0.63), so the sound this game makes a thousand times a round is a plain gain change with the limiter standing still. What is left for the limiter is the stack: 5.93 × 0.6 = 3.56, about 11 dB over the ceiling, which 12:1 above the threshold brings back near 0.73.The five limiter numbers are named constants with a sentence each in
src/engine/sound.ts: threshold −4 dB, knee 6 dB, ratio 12:1, attack 3 ms (faster than a millisecond follows the waveform of a 110 Hz thump instead of its envelope; slower than twenty and a grenade is over before the gain moves), release 250 ms (a rifle is ten rounds a second, so under 100 ms would pump once per round). A context with nocreateDynamicsCompressorplays through the gain alone, with a test on that path.The range gets the same bus because it is the same device: its loudest instant is
3 × 0.14 + 0.22 + 0.16 + 0.10 = 0.90— it never clipped — so after the bus it is 0.54, under the threshold, and the limiter never acts there.src/range/audio.test.tsasserts both.Tests that would fail without this: the fake contexts number every node and record every
connect, sosound.test.tswalks a burst, a plain tone and a panned tone to the destination and says all three arrive at the same gain node, that its gain isMASTER_GAIN, that exactly one edge reaches the speaker and it leaves the limiter, and that a hundred voices build one bus between them.src/client/audio.test.tssums the recipes by playing them, not from a table copied beside them, and fails when a future recipe pushes the total pastLOUDEST_INSTANT— which it also reads back out ofsound.ts, so the docstring's arithmetic cannot drift from the recipes.Why #273's check failed, and what changed since. Nothing in the audio work: the one failing test was
a match that stopped posting reads as abandoned in the feed, which asserted that one second insideIDLE_SECONDSa row still reads as in progress, counting fromDate.now().updated_atis SQLite'sdatetime('now')— whole seconds — so the row is stamped up to 999 ms before the clock the test counted from; measured over 300 writes here the gap reaches 999 ms, one millisecond from turning the assertion over. The test was wrong, not the code: it now takes its clock from the row it wrote, andlivenessis untouched and still checked on both sides of the boundary.What I chose not to do: no recipe in
src/client/audio.tschanged — the measurement says the sum is the problem, not any one recipe — andsrc/client/stage.test-helper.tsis untouched, because that stub installs noAudioContextat all. No dependency was added.To try it: a firefight on
/with a grenade in it, and/rangefor the other side of the same device.Closes #211
🤖 Generated with Claude Code
Closes #211