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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

The check passes locally (typecheck + 1,203 tests, run four times clean) and the branch is pushed.

shipped production is serving it deploy ae07c2d

opened this

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 GainNode made with the context, and that bus is the only thing reaching context.destination — through a DynamicsCompressorNode used as a limiter. Nothing connects to the destination directly any more.

The headroom is arithmetic. LOUDEST_INSTANT in src/client/audio.ts is the loudest thing these recipes can ask for — a grenade landing in a five-man crossfire, every voice scheduled at the same currentTime because 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_GAIN is 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 no createDynamicsCompressor plays 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.ts asserts both.

Tests that would fail without this: the fake contexts number every node and record every connect, so sound.test.ts walks 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 is MASTER_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.ts sums the recipes by playing them, not from a table copied beside them, and fails when a future recipe pushes the total past LOUDEST_INSTANT — which it also reads back out of sound.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 inside IDLE_SECONDS a row still reads as in progress, counting from Date.now(). updated_at is SQLite's datetime('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, and liveness is untouched and still checked on both sides of the boundary.

What I chose not to do: no recipe in src/client/audio.ts changed — the measurement says the sum is the problem, not any one recipe — and src/client/stage.test-helper.ts is untouched, because that stub installs no AudioContext at all. No dependency was added.

To try it: a firefight on / with a grenade in it, and /range for the other side of the same device.

Closes #211

🤖 Generated with Claude Code

Closes #211

Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs