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 #273

Closed opened this
Closed without merging

torque/cs-engine:torque/211-a-firefight-does-not-crackle-one-output → main 1 commit fork

  • Closed without merging. Nothing was applied to main.

Review

Sign in to review this change.
9 files changed +546 −68
README.md +3 −1
@@ -545,7 +545,9 @@ never checked itself.
545545 corner-cutting — which cells are solid or walkable arrive as predicates, so it
546546 knows nothing about de_forge's alphabet), `mesh` (a box builder), `texture`
547547 (hashed value noise, fbm and an atlas painter), `sound` (a lazy `AudioContext`
548 with noise bursts and tones), `loop` (a fixed step with a clamp on a long
548 with noise bursts and tones, every voice of them on one master gain that
549 reaches the speaker through a limiter, because five guns in a room sum past
550 what a sample can hold), `loop` (a fixed step with a clamp on a long
549551 frame), `controls` (a bindings table in, held actions out), `fullscreen` (the stage on
550552 the whole screen: whether this browser will, whether it is right now, the way
551553 in and out, and a refusal swallowed rather than thrown), `escape` (the Escape
src/client/audio.test.ts +64 −6
@@ -1,8 +1,20 @@
11 import { expect, test } from "bun:test";
2 import { createAudio, loudness, shotTone } from "./audio.ts";
3 import type { Burst, SoundDevice, Tone } from "../engine/index.ts";
2 import { readFileSync } from "node:fs";
3 import { join } from "node:path";
4 import { LOUDEST_INSTANT, createAudio, loudness, shotTone, type Audio } from "./audio.ts";
5 import type { Burst, SoundDevice, Tone, Vec3 } from "../engine/index.ts";
46 import type { Sound } from "./world.ts";
57
8 /**
9 * Every noise a round can make, which is the whole of `Sound`: the five a
10 * grenade makes and the five the round does. Written once, because three tests
11 * here walk all of them and a name missing from one of those lists is a recipe
12 * nobody checks — `smoke` was exactly that until the budget below wanted it.
13 */
14 const SOUNDS: Sound[] = [
15 "step", "beep", "plant", "defuse", "death", "throw", "bounce", "blast", "flash", "smoke",
16 ];
17
618 /** A device that plays nothing and writes down everything. */
719 function recorder(): SoundDevice & { bursts: Burst[]; tones: Tone[]; resumes: number } {
820 const device = {
@@ -82,8 +94,7 @@ test("your own boots are quieter than somebody else's taken in the same place",
8294 });
8395
8496 test("every noise a round makes has a recipe, and a far one is quieter than a near one", () => {
85 const names: Sound[] = ["step", "beep", "plant", "defuse", "death", "throw", "bounce", "blast", "flash"];
86 for (const name of names) {
97 for (const name of SOUNDS) {
8798 const device = recorder();
8899 const audio = createAudio(device);
89100 audio.play(name, [0, 0, 0], [0, 0, 0]);
@@ -102,6 +113,54 @@ test("every noise a round makes has a recipe, and a far one is quieter than a ne
102113 }
103114 });
104115
116 // --- the budget -----------------------------------------------------------
117
118 /** What one recipe asks for at zero distance: the peak gain of every voice in it. */
119 function peak(play: (audio: Audio) => void): number {
120 const device = recorder();
121 play(createAudio(device));
122 return [...device.bursts, ...device.tones].reduce((sum, voice) => sum + voice.gain, 0);
123 }
124
125 /**
126 * The headroom, measured rather than asserted. This adds the recipes up from
127 * the recipes — not from a table of numbers copied beside them — and holds the
128 * total against `LOUDEST_INSTANT`, which is the number `src/engine/sound.ts`
129 * sized the master bus and its limiter from. A recipe that grows, or a new loud
130 * name, fails here and is read there.
131 *
132 * The instant is the issue's own: five guns in one room, and the three loudest
133 * other noises a tick can carry in with them. Which three is not written down —
134 * it is whichever three are loudest today — so a new noise louder than a body
135 * falling joins the sum on its own rather than waiting for somebody to notice.
136 */
137 test("the recipes fit the headroom the engine's bus was sized for", () => {
138 const here: Vec3 = [0, 0, 0];
139 const shot = peak((audio) => audio.shot("ak47", here, here));
140 expect(shot).toBeCloseTo(0.65, 9); // 0.35 of noise, 0.30 of thump
141
142 const others = SOUNDS
143 .map((name) => ({ name, gain: peak((audio) => audio.play(name, here, here)) }))
144 .sort((a, b) => b.gain - a.gain);
145 expect(others.slice(0, 3)).toEqual([
146 { name: "blast", gain: expect.closeTo(1.25, 9) },
147 { name: "flash", gain: expect.closeTo(0.85, 9) },
148 { name: "death", gain: expect.closeTo(0.58, 9) },
149 ]);
150
151 const worst = 5 * shot + others.slice(0, 3).reduce((sum, one) => sum + one.gain, 0);
152 expect(worst).toBeCloseTo(5.93, 9);
153 // A nanogain of slack, because this is a sum of floats and the budget is a
154 // statement about a firefight rather than about the last bit of a double.
155 expect(worst).toBeLessThanOrEqual(LOUDEST_INSTANT + 1e-9);
156
157 // And the same line of addition is the reason the bus multiplies by what it
158 // multiplies by, so the docstring that does the arithmetic has to hold this
159 // number: read out of the file rather than trusted to stay true.
160 const device = readFileSync(join(import.meta.dir, "..", "engine", "sound.ts"), "utf8");
161 expect(device).toContain(`= ${LOUDEST_INSTANT}`);
162 });
163
105164 test("the bomb counting is a square tone, and the plant climbs where the defuse falls", () => {
106165 const device = recorder();
107166 const audio = createAudio(device);
@@ -196,8 +255,7 @@ test("a shot is placed the same way, and every node of a noise carries one pan",
196255
197256 // Every recipe places every voice it builds: a name that forgot would leave
198257 // half a noise in the middle while the other half is off to one side.
199 const names: Sound[] = ["step", "beep", "plant", "defuse", "death", "throw", "bounce", "blast", "flash"];
200 for (const name of names) {
258 for (const name of SOUNDS) {
201259 const one = recorder();
202260 createAudio(one).play(name, [900, 0, 0], [0, 0, 0], false, NORTH);
203261 const voices = [...one.bursts, ...one.tones];
src/client/audio.ts +22
@@ -104,6 +104,28 @@ const BOMB_CLOSE = 64;
104104 const DEATH_EARSHOT = 400;
105105 const DEATH_CLOSE = 64;
106106
107 /**
108 * The loudest instant these recipes can ask a device for, as peak gains summed:
109 * a grenade landing in a five-man crossfire. Five gunshots (0.35 of noise and
110 * 0.30 of thump each), the blast (0.70 + 0.55), the flash that came with it
111 * (0.55 + 0.30) and a body hitting the floor (0.34 + 0.24) — every one of them
112 * inside its own full-volume radius, and every voice of it starting at the same
113 * instant, because a frame plays everything that happened since the last frame
114 * at one `currentTime`:
115 *
116 * 5 × 0.65 + 1.25 + 0.85 + 0.58 = 5.93
117 *
118 * Nearly six times the 1.0 a sample can hold, which is what a firefight sounded
119 * like before #211: not a firefight, a crackle. The number lives here because it
120 * is a fact about these recipes and about nothing else, and it is what the
121 * engine's master bus was sized against — `MASTER_GAIN` in `src/engine/sound.ts`
122 * multiplies it down and the limiter on the end of that bus catches the rest.
123 * `audio.test.ts` adds the recipes up from the recipes themselves, fails when a
124 * new one grows past this, and holds this number against the arithmetic written
125 * in `sound.ts` so that the two cannot drift apart.
126 */
127 export const LOUDEST_INSTANT = 5.93;
128
107129 /** The shot a weapon makes: its filter colour, its thump and how long it rings. */
108130 export const shotTone = (weapon: WeaponId): { cut: number; thump: number; length: number } =>
109131 TONE[WEAPONS[weapon]?.kind ?? "rifle"] ?? (TONE.rifle as { cut: number; thump: number; length: number });
src/engine/GUIDE.md +18 −4
@@ -339,7 +339,7 @@ call: the range's three 64-pixel layers are about 17 ms, and `paintAtlas` is
339339
340340 ### sound
341341
342 **Weight**: 1.8 kB raw, 1.0 kB gzipped — this module alone.
342 **Weight**: 2.2 kB raw, 1.1 kB gzipped — this module alone.
343343
344344 `createSoundDevice` gives a `SoundDevice` that opens its `AudioContext` on the
345345 first sound, never at import — hand it a `Ctor` of your own and a test can count
@@ -353,7 +353,7 @@ is why a test can import it in a process with no `AudioContext` in it.
353353
354354 The device is positional, in one dimension. Both `Burst` and `Tone` take an
355355 optional `Burst.pan`, −1 hard left to +1 hard right, and a voice that carries
356 one is routed through a `StereoPannerNode` on its way to the destination.
356 one is routed through a `StereoPannerNode` on its way to the bus.
357357 `panOf` is the arithmetic that works one out, from a source, an ear and the
358358 heading the ear faces. That heading is in degrees, the same angle
359359 `angleVectors` takes, and the answer is which side of that facing the source is
@@ -364,12 +364,26 @@ not flip speakers in one frame. Front and back are not in the answer and cannot
364364 be: two speakers say left and right, and the game does not pay for a head model
365365 to pretend otherwise.
366366
367 Two things that pass through this seam quietly. A pan is validated where it is
367 Everything a device plays leaves by one node. Every voice ends at a master
368 `GainNode` made once with the context — `sound.MASTER_GAIN` of what it was —
369 and that bus reaches the destination through a `DynamicsCompressorNode` set up
370 as a limiter (`sound.LIMIT_THRESHOLD` and the four constants beside it, each
371 with its sentence). Nothing connects to the destination itself, because a voice
372 cannot know what else is playing: the game asks for a gunshot, not for a level,
373 and the only node that can see the sum is the one that has to hold it under
374 1.0. The gain is arithmetic and the docstring shows the addition — the loudest
375 instant `src/client/audio.ts` can ask for is 5.93, nearly six times what a
376 sample holds, and `src/client/audio.test.ts` adds the recipes up and fails when
377 a new one grows past that. A context with no `createDynamicsCompressor` plays
378 through the gain alone: quieter than it was, with no ceiling on it, which is the
379 game as it was rather than a thrown frame.
380
381 Two more things that pass through this seam quietly. A pan is validated where it is
368382 used — a NaN or a number outside −1..1 is clamped to centre, never handed to a
369383 node — and a voice with **no** pan at all builds no panner and is wired exactly
370384 as it was before panning existed, which is what the range does today. And a
371385 context with no `createStereoPanner` — the oldest Safari, and every hand-written
372 double in this repository — connects straight to the destination instead of
386 double in this repository — connects straight to the bus instead of
373387 throwing, so a fake that never grew the method plays the sound centred rather
374388 than dropping the frame.
375389
src/engine/guide.test.ts +4 −2
@@ -61,10 +61,12 @@ const BARE = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
6161 * this file ignores without needing to be told about it.
6262 */
6363 const NOT_THE_ENGINES = new Set([
64 "AudioContext", "Float32Array", "Float64Array", "Int32Array", "StereoPannerNode",
64 "AudioContext", "DynamicsCompressorNode", "Float32Array", "Float64Array", "GainNode",
65 "Int32Array", "StereoPannerNode",
6566 "TEXTURE_2D_ARRAY",
6667 "addEventListener",
67 "cancelAnimationFrame", "createStereoPanner", "de_forge", "fullscreenElement",
68 "cancelAnimationFrame", "createDynamicsCompressor", "createStereoPanner", "de_forge",
69 "fullscreenElement",
6870 "pointerLockElement",
6971 "removeEventListener", "requestAnimationFrame", "uniformMatrix4fv",
7072 ]);
src/engine/sound.test.ts +203 −23
@@ -1,5 +1,8 @@
11 import { expect, test } from "bun:test";
2 import { attenuate, createSoundDevice, HEAD, panOf, type Ctor } from "./sound.ts";
2 import {
3 attenuate, createSoundDevice, HEAD, LIMIT_ATTACK, LIMIT_KNEE, LIMIT_RATIO, LIMIT_RELEASE,
4 LIMIT_THRESHOLD, MASTER_GAIN, panOf, type Ctor,
5 } from "./sound.ts";
36
47 // --- the arithmetic -------------------------------------------------------
58
@@ -77,47 +80,78 @@ test("panOf never leaves -1..1, and nonsense in is centre out", () => {
7780
7881 type Event = { kind: string; at: number; value: number; time: number };
7982
83 /** One `connect` the device made, as the node it left and the node it reached. */
84 type Edge = [from: string, to: string];
85
86 /** One node this context handed out: what it is, which one it is, and its knobs. */
87 type Made = { id: string; kind: string; params: Record<string, { value: number }> };
88
8089 /**
8190 * The smallest thing that answers like an AudioContext, and remembers.
8291 * `panner: false` is a context without `createStereoPanner` — the oldest
83 * Safari, and every double that was written before this repository could pan.
92 * Safari, and every double that was written before this repository could pan;
93 * `compressor: false` is one without `createDynamicsCompressor`, which is the
94 * same browser and the path the bus falls back to.
95 *
96 * Every node it hands out is numbered in the order it was made, so `edges` is
97 * the graph the device built and not a count of hops: a test can follow one
98 * voice from its source to the destination and say which nodes it went through.
8499 */
85 function fakeContext(options: { panner?: boolean } = {}): {
86 Ctor: Ctor; log: Event[]; buffers: Float32Array[]; panners: { value: number }[];
87 contexts: number; resumes: number;
100 function fakeContext(options: { panner?: boolean; compressor?: boolean } = {}): {
101 Ctor: Ctor; log: Event[]; edges: Edge[]; nodes: Made[]; buffers: Float32Array[];
102 panners: { value: number }[]; contexts: number; resumes: number;
88103 } {
89104 const log: Event[] = [];
105 const edges: Edge[] = [];
106 const nodes: Made[] = [];
90107 const buffers: Float32Array[] = [];
91108 /** The `pan` of every `StereoPannerNode` this context handed out, in order. */
92109 const panners: { value: number }[] = [];
93 const state = { contexts: 0, resumes: 0 };
110 const state = { contexts: 0, resumes: 0, made: 0 };
94111 const param = (kind: string) => ({
95112 value: 0,
96113 setValueAtTime: (value: number, time: number) => log.push({ kind: `${kind}.set`, at: 0, value, time }),
97114 exponentialRampToValueAtTime: (value: number, time: number) =>
98115 log.push({ kind: `${kind}.ramp`, at: 0, value, time }),
99116 });
100 const node = (kind: string) => ({
101 kind,
102 pan: param(`${kind}.pan`),
103 frequency: param(`${kind}.frequency`),
104 playbackRate: param(`${kind}.rate`),
105 gain: param(`${kind}.gain`),
106 Q: param(`${kind}.q`),
107 type: "",
108 buffer: null as unknown,
109 connect(next: unknown) {
110 log.push({ kind: `${kind}.connect`, at: 0, value: 0, time: 0 });
111 return next;
112 },
113 start: (time: number) => log.push({ kind: `${kind}.start`, at: 0, value: 0, time }),
114 stop: (time: number) => log.push({ kind: `${kind}.stop`, at: 0, value: 0, time }),
115 });
117 const node = (kind: string) => {
118 const id = `${kind}${(state.made += 1)}`;
119 // Every knob a node in this repository is given, on every node — the
120 // limiter's five included: a fake that answers less than the thing it
121 // doubles is how a thrown frame hides.
122 const params = {
123 pan: param(`${kind}.pan`),
124 frequency: param(`${kind}.frequency`),
125 playbackRate: param(`${kind}.rate`),
126 gain: param(`${kind}.gain`),
127 Q: param(`${kind}.q`),
128 threshold: param(`${kind}.threshold`),
129 knee: param(`${kind}.knee`),
130 ratio: param(`${kind}.ratio`),
131 attack: param(`${kind}.attack`),
132 release: param(`${kind}.release`),
133 };
134 nodes.push({ id, kind, params });
135 return {
136 kind,
137 id,
138 ...params,
139 type: "",
140 buffer: null as unknown,
141 connect(next: unknown) {
142 log.push({ kind: `${kind}.connect`, at: 0, value: 0, time: 0 });
143 edges.push([id, (next as { id?: string }).id ?? "destination"]);
144 return next;
145 },
146 start: (time: number) => log.push({ kind: `${kind}.start`, at: 0, value: 0, time }),
147 stop: (time: number) => log.push({ kind: `${kind}.stop`, at: 0, value: 0, time }),
148 };
149 };
116150 class Fake {
117151 sampleRate = 48000;
118152 currentTime = 2;
119153 state = "suspended";
120 destination = { kind: "destination" };
154 destination = { kind: "destination", id: "destination" };
121155 constructor() {
122156 state.contexts += 1;
123157 }
@@ -142,9 +176,15 @@ function fakeContext(options: { panner?: boolean } = {}): {
142176 return made;
143177 };
144178 }
179 if (options.compressor !== false) {
180 (Fake.prototype as { createDynamicsCompressor?: () => unknown }).createDynamicsCompressor = () =>
181 node("limiter");
182 }
145183 return {
146184 Ctor: Fake as unknown as Ctor,
147185 log,
186 edges,
187 nodes,
148188 buffers,
149189 panners,
150190 get contexts() { return state.contexts; },
@@ -154,6 +194,25 @@ function fakeContext(options: { panner?: boolean } = {}): {
154194
155195 const find = (log: Event[], kind: string): Event | undefined => log.find((e) => e.kind === kind);
156196
197 /**
198 * Where a node's sound goes, one hop at a time, starting with the node itself.
199 * Every node the device builds connects to exactly one thing, so the walk is a
200 * path and not a search; a cycle would stop it rather than hang the suite.
201 */
202 function route(edges: Edge[], from: string): string[] {
203 const path = [from];
204 for (let at = from; path.length < 16; ) {
205 const hop = edges.find(([source]) => source === at);
206 if (hop === undefined) break;
207 at = hop[1];
208 path.push(at);
209 }
210 return path;
211 }
212
213 /** The kinds along a route: `source`, `filter`, `gain`, `limiter`, `destination`. */
214 const kinds = (path: string[]): string[] => path.map((id) => id.replace(/\d+$/, ""));
215
157216 test("a device with no AudioContext at all stays quiet instead of throwing", () => {
158217 const device = createSoundDevice(undefined);
159218 device.resume();
@@ -220,6 +279,127 @@ test("a muted device builds no nodes at all", () => {
220279 expect(fake.log.length).toBeGreaterThan(0);
221280 });
222281
282 // --- the bus, and the limiter on the end of it -----------------------------
283
284 /**
285 * The wiring this issue is about, read off the graph the device built: a burst,
286 * a tone and a placed tone all arrive at the *same* gain node, and that node —
287 * not any of them — is what reaches the destination, through the limiter.
288 */
289 test("every voice ends at one master bus, and only the bus reaches the destination", () => {
290 const fake = fakeContext();
291 const device = createSoundDevice(fake.Ctor);
292 device.noiseBurst({ frequency: 1500, gain: 0.35, length: 0.2 });
293 device.tone({ frequency: 440, gain: 0.06, length: 0.09 });
294 device.tone({ frequency: 150, gain: 0.3, length: 0.16, pan: -0.5 });
295
296 const burst = route(fake.edges, "source3");
297 const plain = route(fake.edges, "osc6");
298 const placed = route(fake.edges, "osc8");
299 expect(kinds(burst)).toEqual(["source", "filter", "gain", "gain", "limiter", "destination"]);
300 expect(kinds(plain)).toEqual(["osc", "gain", "gain", "limiter", "destination"]);
301 expect(kinds(placed)).toEqual(["osc", "gain", "panner", "gain", "limiter", "destination"]);
302
303 // One bus for all three, made with the context rather than with a voice: it
304 // is the first node this context ever handed out, and the three envelopes and
305 // the panner all end on it.
306 const bus = fake.nodes[0] as Made;
307 expect(bus.id).toBe("gain1");
308 expect(burst.at(-3)).toBe(bus.id);
309 expect(plain.at(-3)).toBe(bus.id);
310 expect(placed.at(-3)).toBe(bus.id);
311 expect(bus.params.gain?.value).toBe(MASTER_GAIN);
312
313 // And nothing else goes to the speaker: exactly one edge into the destination,
314 // and it comes from the limiter the bus feeds.
315 const out = fake.edges.filter(([, to]) => to === "destination");
316 expect(out).toEqual([["limiter2", "destination"]]);
317 expect(fake.edges).toContainEqual([bus.id, "limiter2"]);
318 });
319
320 /**
321 * The limiter's five numbers, each of which is a constant with a sentence on
322 * it in `sound.ts`. They are asserted as the values a browser is handed rather
323 * than as the constants, because a parameter set on the wrong knob — attack on
324 * release is the classic — type-checks and sounds like a pumping mess.
325 */
326 test("the bus reaches the destination through a limiter set to the stated numbers", () => {
327 const fake = fakeContext();
328 createSoundDevice(fake.Ctor).tone({ frequency: 440, gain: 0.06, length: 0.09 });
329 const limiter = fake.nodes.find((made) => made.kind === "limiter") as Made;
330 expect(limiter.id).toBe("limiter2"); // built with the bus, once, not per voice
331 expect(limiter.params.threshold?.value).toBe(-4);
332 expect(limiter.params.knee?.value).toBe(6);
333 expect(limiter.params.ratio?.value).toBe(12);
334 expect(limiter.params.attack?.value).toBe(0.003);
335 expect(limiter.params.release?.value).toBe(0.25);
336 expect([LIMIT_THRESHOLD, LIMIT_KNEE, LIMIT_RATIO, LIMIT_ATTACK, LIMIT_RELEASE])
337 .toEqual([-4, 6, 12, 0.003, 0.25]);
338 });
339
340 test("a hundred voices build one bus and one limiter between them", () => {
341 const fake = fakeContext();
342 const device = createSoundDevice(fake.Ctor);
343 for (let i = 0; i < 50; i++) {
344 device.noiseBurst({ frequency: 1500, gain: 0.35, length: 0.2 });
345 device.tone({ frequency: 440, gain: 0.06, length: 0.09 });
346 }
347 expect(fake.nodes.filter((made) => made.kind === "limiter").length).toBe(1);
348 // One bus plus one envelope a voice: the master gain is not per voice.
349 expect(fake.nodes.filter((made) => made.kind === "gain").length).toBe(1 + 100);
350 });
351
352 /**
353 * The single gunshot, in numbers: the recipe in `src/client/audio.ts` asks for
354 * 0.35 of noise and 0.30 of thump, and the bus is the only thing between that
355 * and the speaker when nothing else is playing. What it costs is exactly
356 * `MASTER_GAIN` — 0.65 becomes 0.39, and 0.39 is under the −4 dBFS threshold
357 * (0.63), so the limiter is not moving while a player hears it.
358 */
359 test("a quiet round pays the headroom and nothing else", () => {
360 const fake = fakeContext();
361 const device = createSoundDevice(fake.Ctor);
362 device.noiseBurst({ frequency: 1500, gain: 0.35, length: 0.24 });
363 device.tone({ frequency: 110, gain: 0.3, length: 0.24 });
364 const peaks = fake.log.filter((e) => e.kind === "gain.gain.set").map((e) => e.value);
365 expect(peaks).toEqual([0.35, 0.3]); // the envelopes are untouched
366 const shot = (0.35 + 0.3) * MASTER_GAIN;
367 expect(shot).toBeCloseTo(0.39, 6);
368 expect(shot).toBeLessThan(10 ** (LIMIT_THRESHOLD / 20));
369 });
370
371 /**
372 * A browser with no `createDynamicsCompressor` — the oldest Safari, and a fake
373 * that never grew the method. The bus is still there and still multiplies, so
374 * the page is quieter than it was rather than louder than it can be; there is
375 * simply no ceiling on it, which is the game exactly as it was before this.
376 */
377 test("a context with no createDynamicsCompressor plays through the gain alone", () => {
378 const fake = fakeContext({ compressor: false });
379 const device = createSoundDevice(fake.Ctor);
380 device.noiseBurst({ frequency: 1500, gain: 0.35, length: 0.2 });
381 device.tone({ frequency: 440, gain: 0.06, length: 0.09, pan: 0.5 });
382 expect(fake.nodes.some((made) => made.kind === "limiter")).toBe(false);
383 expect(kinds(route(fake.edges, "source2"))).toEqual(["source", "filter", "gain", "gain", "destination"]);
384 expect(kinds(route(fake.edges, "osc5"))).toEqual(["osc", "gain", "panner", "gain", "destination"]);
385 expect((fake.nodes[0] as Made).params.gain?.value).toBe(MASTER_GAIN);
386 // Both voices still started: the sound was made, not skipped.
387 expect(find(fake.log, "source.start")).toBeDefined();
388 expect(find(fake.log, "osc.start")).toBeDefined();
389 });
390
391 test("a muted device builds no bus at all, and one that is unmuted builds it once", () => {
392 const fake = fakeContext();
393 const device = createSoundDevice(fake.Ctor);
394 device.muted = true;
395 for (let i = 0; i < 20; i++) device.noiseBurst({ frequency: 1500, gain: 0.35, length: 0.2 });
396 expect(fake.contexts).toBe(0);
397 expect(fake.nodes).toEqual([]);
398 device.muted = false;
399 device.noiseBurst({ frequency: 1500, gain: 0.35, length: 0.2 });
400 expect(fake.nodes.filter((made) => made.kind === "gain").length).toBe(2);
401 });
402
223403 // --- panning, and the browser that cannot ---------------------------------
224404
225405 test("a voice with a pan goes through one panner per voice, set to the pan asked", () => {
src/engine/sound.ts +138 −26
@@ -3,7 +3,14 @@
33 * through a resonant filter, which is anything that hits or explodes, and a
44 * tone that can slide, which is anything that beeps or thumps. Both are shaped
55 * by one decaying envelope, optionally placed left or right of the ear, and
6 * then go to the destination.
6 * then go to the master bus.
7 *
8 * There is exactly one place the sound leaves by: a `GainNode` made once with
9 * the context, reaching the destination through a `DynamicsCompressorNode` set
10 * up as a limiter. Nothing here connects to `context.destination` itself. A
11 * voice cannot know what else is playing — the game asks for a gunshot, not for
12 * a level — so the one node that can see the sum is the one that has to hold it
13 * under 1.0, and `MASTER_GAIN` below is the arithmetic that sizes it.
714 *
815 * The device is positional in exactly one dimension: a voice may carry a `pan`,
916 * −1 hard left to +1 hard right, and `panOf` is the arithmetic that works one
@@ -103,26 +110,129 @@ const panValue = (pan: number): number =>
103110
104111 const NOISE_SECONDS = 0.5;
105112
113 /**
114 * What the bus multiplies every voice by before the limiter sees it. The number
115 * is arithmetic on the recipes that are actually in this repository, not taste.
116 *
117 * The loudest instant `src/client/audio.ts` can ask for is a grenade landing in
118 * a five-man crossfire: five gunshots (0.35 burst + 0.30 thump each), the blast
119 * (0.70 + 0.55), the flash that came with it (0.55 + 0.30) and a body hitting
120 * the floor (0.34 + 0.24), each of them inside its own full-volume radius and
121 * every voice of it scheduled at the same `currentTime`, because a frame plays
122 * everything that happened since the last one at once:
123 *
124 * 5 × 0.65 + 1.25 + 0.85 + 0.58 = 5.93
125 *
126 * Nearly six times the 1.0 a sample can hold, which is the crackle. The peak of
127 * a sum is not usually the sum of the peaks — noise is noise and the phases
128 * differ — but it is the bound the device can be asked for, and it is what the
129 * bus has to be sized against. `src/client/audio.test.ts` adds the same recipes
130 * up from the recipes themselves and fails when a new one pushes the total past
131 * the 5.93 in this paragraph.
132 *
133 * 0.6 is chosen from the other end. One gunshot alone is the sound this game
134 * makes a thousand times a round, and 0.65 × 0.6 = 0.39 leaves it under
135 * `LIMIT_THRESHOLD`: the common sound is a plain gain change and never a
136 * compressor moving. What is left for the limiter is the stack — 5.93 × 0.6 =
137 * 3.56, about 11 dB over the ceiling — and that is all it is there to catch.
138 */
139 export const MASTER_GAIN = 0.6;
140
141 /**
142 * Where the limiter starts working, in dBFS. −4 dB is 0.63 linear: above a
143 * single gunshot on the bus (0.39, −8.2 dB), so the common sound passes
144 * untouched, and far enough under 1.0 that the ratio has room to act before a
145 * sample would clip.
146 */
147 export const LIMIT_THRESHOLD = -4;
148
149 /**
150 * How many dB the curve takes to bend from untouched to limited. 6 dB, so the
151 * second gun in the room arrives as a level that leans rather than as a switch
152 * being thrown: a hard knee here is audible as the moment the mix ducks.
153 */
154 export const LIMIT_KNEE = 6;
155
156 /**
157 * How much of what is over the threshold survives it. 12:1 is limiting rather
158 * than compression — 15 dB over comes out 1.25 dB over — which is what turns
159 * the 3.56 of a worst instant back into something under the ceiling.
160 */
161 export const LIMIT_RATIO = 12;
162
163 /**
164 * How long the limiter takes to reach its gain, in seconds. This is the number
165 * a wrong answer ruins the game with. Faster than about a millisecond and the
166 * gain follows the waveform of a 110 Hz thump instead of its envelope, which is
167 * distortion; slower than about twenty and a grenade is over before the gain
168 * moves at all, so the peak that needed catching is the one that got through.
169 * 3 ms lets the very front of a single crack pass — one transient is not the
170 * problem — and is closed for the body of the volley behind it.
171 */
172 export const LIMIT_ATTACK = 0.003;
173
174 /**
175 * How long it takes to let go, in seconds. A rifle is ten rounds a second, so a
176 * release under 100 ms would recover between shots and every burst would pump
177 * once per round; 250 ms holds one level across the burst and is still short
178 * enough that the quiet after a fight is back to full within a step.
179 */
180 export const LIMIT_RELEASE = 0.25;
181
106182 const defaultCtor = (): Ctor | undefined => {
107183 const g = globalThis as unknown as { AudioContext?: Ctor; webkitAudioContext?: Ctor };
108184 return g.AudioContext ?? g.webkitAudioContext;
109185 };
110186
111 export function createSoundDevice(Ctor: Ctor | undefined = defaultCtor()): SoundDevice {
112 let ctx: AudioContext | null = null;
113 let noise: AudioBuffer | null = null;
187 /**
188 * The limiter on the end of the bus, or null where this browser has no
189 * compressor to give. Every parameter is a constant above with its sentence;
190 * they are set through `value` rather than scheduled, because the limiter is
191 * built once and never automated.
192 */
193 function limiterFor(context: AudioContext): AudioNode | null {
194 const make = (context as Partial<AudioContext>).createDynamicsCompressor;
195 if (typeof make !== "function") return null;
196 const limiter = make.call(context);
197 limiter.threshold.value = LIMIT_THRESHOLD;
198 limiter.knee.value = LIMIT_KNEE;
199 limiter.ratio.value = LIMIT_RATIO;
200 limiter.attack.value = LIMIT_ATTACK;
201 limiter.release.value = LIMIT_RELEASE;
202 return limiter;
203 }
114204
115 /** The context, opened on first use, or null where there is no audio at all. */
116 const ensure = (): AudioContext | null => {
117 if (ctx === null) {
205 /** The device, once it has been opened: the context, the bus, and the noise. */
206 type Wiring = { context: AudioContext; bus: AudioNode; noise: AudioBuffer };
207
208 export function createSoundDevice(Ctor: Ctor | undefined = defaultCtor()): SoundDevice {
209 let wiring: Wiring | null = null;
210
211 /**
212 * The device, opened on first use, or null where there is no audio at all.
213 * The bus and its limiter are built here, with the context and the noise:
214 * two nodes and one buffer for the life of the page rather than a pair per
215 * voice, and a page that never makes a sound builds none of them.
216 */
217 const ensure = (): Wiring | null => {
218 if (wiring === null) {
118219 if (Ctor === undefined) return null;
119 ctx = new Ctor();
120 noise = ctx.createBuffer(1, Math.round(ctx.sampleRate * NOISE_SECONDS), ctx.sampleRate);
220 const context = new Ctor();
221 const noise = context.createBuffer(1, Math.round(context.sampleRate * NOISE_SECONDS), context.sampleRate);
121222 const data = noise.getChannelData(0);
122223 for (let i = 0; i < data.length; i++) data[i] = Math.random() * 2 - 1;
224 const bus = context.createGain();
225 bus.gain.value = MASTER_GAIN;
226 const limiter = limiterFor(context);
227 // A context with no compressor — the oldest Safari, and a fake that never
228 // grew the method — plays through the gain alone. Quieter than it was and
229 // no ceiling on it, which is the game as it was, and not a thrown frame.
230 if (limiter === null) bus.connect(context.destination);
231 else bus.connect(limiter).connect(context.destination);
232 wiring = { context, bus, noise };
123233 }
124 if (ctx.state === "suspended") void ctx.resume();
125 return ctx;
234 if (wiring.context.state === "suspended") void wiring.context.resume();
235 return wiring;
126236 };
127237
128238 /** A gain node that decays from `gain` to `floor` over `length` seconds. */
@@ -134,19 +244,19 @@ export function createSoundDevice(Ctor: Ctor | undefined = defaultCtor()): Sound
134244 };
135245
136246 /**
137 * What a voice ends on: one `StereoPannerNode` into the destination when a
138 * pan was asked for, and the destination itself when it was not — or when
139 * this browser has no panner to give, which is the oldest Safari and every
140 * hand-written fake context in this repository. A voice with no pan costs no
141 * node, so a game that never places a sound is wired exactly as before.
247 * What a voice ends on: one `StereoPannerNode` into the bus when a pan was
248 * asked for, and the bus itself when it was not — or when this browser has no
249 * panner to give, which is the oldest Safari and every hand-written fake
250 * context in this repository. A voice with no pan costs no node, so a game
251 * that never places a sound is wired as it was, one hop shorter.
142252 */
143 const sink = (context: AudioContext, pan: number | undefined): AudioNode => {
144 if (pan === undefined) return context.destination;
253 const sink = ({ context, bus }: Wiring, pan: number | undefined): AudioNode => {
254 if (pan === undefined) return bus;
145255 const make = (context as Partial<AudioContext>).createStereoPanner;
146 if (typeof make !== "function") return context.destination;
256 if (typeof make !== "function") return bus;
147257 const panner = make.call(context);
148258 panner.pan.value = panValue(pan);
149 panner.connect(context.destination);
259 panner.connect(bus);
150260 return panner;
151261 };
152262
@@ -156,8 +266,9 @@ export function createSoundDevice(Ctor: Ctor | undefined = defaultCtor()): Sound
156266
157267 noiseBurst({ frequency, gain, length, q = 0.9, rate = 1, pan }) {
158268 if (device.muted) return;
159 const context = ensure();
160 if (context === null || noise === null) return;
269 const open = ensure();
270 if (open === null) return;
271 const { context, noise } = open;
161272 const now = context.currentTime;
162273 const source = context.createBufferSource();
163274 source.buffer = noise;
@@ -166,15 +277,16 @@ export function createSoundDevice(Ctor: Ctor | undefined = defaultCtor()): Sound
166277 filter.type = "bandpass";
167278 filter.frequency.value = frequency;
168279 filter.Q.value = q;
169 source.connect(filter).connect(decay(context, now, gain, length, 0.0008)).connect(sink(context, pan));
280 source.connect(filter).connect(decay(context, now, gain, length, 0.0008)).connect(sink(open, pan));
170281 source.start(now);
171282 source.stop(now + length);
172283 },
173284
174285 tone({ frequency, gain, length, slideTo, type = "sine", floor = 0.0008, tail = 0, pan }) {
175286 if (device.muted) return;
176 const context = ensure();
177 if (context === null) return;
287 const open = ensure();
288 if (open === null) return;
289 const { context } = open;
178290 const now = context.currentTime;
179291 const osc = context.createOscillator();
180292 osc.type = type;
@@ -183,7 +295,7 @@ export function createSoundDevice(Ctor: Ctor | undefined = defaultCtor()): Sound
183295 osc.frequency.setValueAtTime(frequency, now);
184296 osc.frequency.exponentialRampToValueAtTime(slideTo, now + length);
185297 }
186 osc.connect(decay(context, now, gain, length, floor)).connect(sink(context, pan));
298 osc.connect(decay(context, now, gain, length, floor)).connect(sink(open, pan));
187299 osc.start(now);
188300 osc.stop(now + length + tail);
189301 },
src/range/audio.test.ts +81 −6
@@ -18,13 +18,22 @@ type Event = { kind: string; value: number; time: number };
1818
1919 type Param = { value: number };
2020
21 type Node = { kind: string; type: string; frequency: Param; gain: Param; Q: Param };
21 type Node = {
22 kind: string; id: string; type: string; frequency: Param; gain: Param; Q: Param;
23 threshold: Param; knee: Param; ratio: Param; attack: Param; release: Param;
24 };
25
26 /** One `connect` the device made: the node it left, and the node it reached. */
27 type Edge = [from: string, to: string];
2228
2329 /** The smallest thing that answers like an `AudioContext`, and remembers. */
24 function fakeAudio(): { Ctor: Ctor; log: Event[]; nodes: Node[]; readonly contexts: number } {
30 function fakeAudio(): {
31 Ctor: Ctor; log: Event[]; nodes: Node[]; edges: Edge[]; readonly contexts: number;
32 } {
2533 const log: Event[] = [];
2634 const nodes: Node[] = [];
27 const state = { contexts: 0 };
35 const edges: Edge[] = [];
36 const state = { contexts: 0, made: 0 };
2837 const param = (kind: string): Param => ({
2938 value: 0,
3039 setValueAtTime: (value: number, time: number) => log.push({ kind: `${kind}.set`, value, time }),
@@ -32,8 +41,10 @@ function fakeAudio(): { Ctor: Ctor; log: Event[]; nodes: Node[]; readonly contex
3241 log.push({ kind: `${kind}.ramp`, value, time }),
3342 }) as Param;
3443 const node = (kind: string): Node => {
44 const id = `${kind}${(state.made += 1)}`;
3545 const made = {
3646 kind,
47 id,
3748 type: "",
3849 buffer: null as unknown,
3950 pan: param(`${kind}.pan`),
@@ -41,7 +52,17 @@ function fakeAudio(): { Ctor: Ctor; log: Event[]; nodes: Node[]; readonly contex
4152 playbackRate: param(`${kind}.rate`),
4253 gain: param(`${kind}.gain`),
4354 Q: param(`${kind}.q`),
44 connect: (next: unknown) => next,
55 // The limiter's five knobs. The range sets none of them itself — the
56 // engine's bus does — and a double without them is a thrown frame.
57 threshold: param(`${kind}.threshold`),
58 knee: param(`${kind}.knee`),
59 ratio: param(`${kind}.ratio`),
60 attack: param(`${kind}.attack`),
61 release: param(`${kind}.release`),
62 connect: (next: unknown) => {
63 edges.push([id, (next as { id?: string }).id ?? "destination"]);
64 return next;
65 },
4566 start: (time: number) => log.push({ kind: `${kind}.start`, value: 0, time }),
4667 stop: (time: number) => log.push({ kind: `${kind}.stop`, value: 0, time }),
4768 };
@@ -52,7 +73,7 @@ function fakeAudio(): { Ctor: Ctor; log: Event[]; nodes: Node[]; readonly contex
5273 sampleRate = 48000;
5374 currentTime = 0;
5475 state = "running";
55 destination = { kind: "destination" };
76 destination = { kind: "destination", id: "destination" };
5677 constructor() {
5778 state.contexts += 1;
5879 }
@@ -70,21 +91,28 @@ function fakeAudio(): { Ctor: Ctor; log: Event[]; nodes: Node[]; readonly contex
7091 // — but a double that answers less than the thing it doubles is how a
7192 // thrown frame hides until the day somebody adds the argument.
7293 createStereoPanner() { return node("panner"); }
94 // And a compressor, for the same reason: the engine's device puts one on
95 // the end of its bus, so this range is heard through one too.
96 createDynamicsCompressor() { return node("limiter"); }
7397 }
7498 return {
7599 Ctor: Fake as unknown as Ctor,
76100 log,
77101 nodes,
102 edges,
78103 get contexts() { return state.contexts; },
79104 };
80105 }
81106
82107 /** The range's voice over a device over a fake context, and everything it wrote. */
83 function audio(): { log: Event[]; nodes: Node[]; readonly contexts: number; voice: RangeAudio } {
108 function audio(): {
109 log: Event[]; nodes: Node[]; edges: Edge[]; readonly contexts: number; voice: RangeAudio;
110 } {
84111 const fake = fakeAudio();
85112 return {
86113 log: fake.log,
87114 nodes: fake.nodes,
115 edges: fake.edges,
88116 get contexts() { return fake.contexts; },
89117 voice: createRangeAudio(createSoundDevice(fake.Ctor)),
90118 };
@@ -183,6 +211,53 @@ test("the clock running out is one falling tone, once each time it is asked for"
183211 expect(length(a.log, "osc")).toBeCloseTo(OVER.length, 9);
184212 });
185213
214 // --- the bus the engine puts under all of it ------------------------------
215
216 /**
217 * The range's device is the engine's device, so the range is heard through the
218 * engine's master bus and the limiter on it (#211). Every voice the five verbs
219 * start ends on the same gain node, and that node is the only thing that
220 * reaches the speaker.
221 */
222 test("every voice of every verb ends at one bus, and the bus at the destination", () => {
223 const a = audio();
224 a.voice.shot();
225 a.voice.hit(400);
226 a.voice.pop(400);
227 a.voice.tick();
228 a.voice.over();
229 const bus = a.nodes[0] as Node;
230 expect(bus.kind).toBe("gain"); // made with the context, before any voice
231 const limiter = a.nodes[1] as Node;
232 expect(limiter.kind).toBe("limiter");
233 // One edge into the speaker in the whole run, and it leaves the limiter.
234 expect(a.edges.filter(([, to]) => to === "destination")).toEqual([[limiter.id, "destination"]]);
235 expect(a.edges).toContainEqual([bus.id, limiter.id]);
236 // Every envelope — one a voice, five voices — ends on that one bus.
237 const envelopes = a.nodes.filter((node) => node.kind === "gain" && node.id !== bus.id);
238 expect(envelopes.length).toBe(5);
239 for (const envelope of envelopes) expect(a.edges).toContainEqual([envelope.id, bus.id]);
240 });
241
242 /**
243 * And what that costs the range, in numbers. The loudest instant this game can
244 * ask for is the three plates a pop-up drill stands at once, a crack, the hit
245 * that took one down and a clock tick, all in the same frame:
246 *
247 * 3 × 0.14 + 0.22 + 0.16 + 0.10 = 0.90
248 *
249 * Under 1.0 before the bus, so the range never clipped and this issue was never
250 * about it. After the bus it is 0.54, which is under the limiter's −4 dBFS
251 * threshold (0.63): the range is the same mix it was, 0.6 of the level, and the
252 * limiter never moves while it plays.
253 */
254 test("the range's loudest instant is quiet enough that the limiter never acts", () => {
255 const loudest = 3 * POP.gain + CRACK.gain + HIT.gain + TICK.gain;
256 expect(loudest).toBeCloseTo(0.9, 9);
257 expect(loudest).toBeLessThan(1);
258 expect(loudest * 0.6).toBeLessThan(10 ** (-4 / 20));
259 });
260
186261 // --- the arithmetic of the room -------------------------------------------
187262
188263 test("loudness is full at the near end of the lane and a quarter at the far end", () => {
src/range/stage.test-helper.ts +13
@@ -122,6 +122,14 @@ export function audioCounter(): FakeAudio {
122122 frequency: param(() => {}),
123123 playbackRate: param(() => {}),
124124 Q: param(() => {}),
125 // The limiter's five knobs, which the engine's bus sets on the node it
126 // gets from `createDynamicsCompressor` below. A double without them is a
127 // page that throws on its first sound and a test that never says why.
128 threshold: param(() => {}),
129 knee: param(() => {}),
130 ratio: param(() => {}),
131 attack: param(() => {}),
132 release: param(() => {}),
125133 gain: param((value) => void (state.gain = value)),
126134 connect: (next: unknown) => next,
127135 start: () =>
@@ -151,6 +159,11 @@ export function audioCounter(): FakeAudio {
151159 // — but a double that answers less than the thing it doubles is how a
152160 // thrown frame hides until the day somebody adds the argument.
153161 createStereoPanner() { return node("panner"); }
162 // And a compressor, for the same reason: every voice the engine's device
163 // starts goes to one master bus and out through a limiter on it, so a page
164 // whose context could not build one would be a page this counter never
165 // counted the same way a browser does.
166 createDynamicsCompressor() { return node("limiter"); }
154167 }
155168 return {
156169 get contexts() { return state.contexts; },

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs