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
torque/cs-engine:torque/211-a-firefight-does-not-crackle-one-output → main 1 commit fork
9 files
changed +546
−68
README.md +3 −1
| @@ -545,7 +545,9 @@ never checked itself. | ||
| 545 | 545 | corner-cutting — which cells are solid or walkable arrive as predicates, so it |
| 546 | 546 | knows nothing about de_forge's alphabet), `mesh` (a box builder), `texture` |
| 547 | 547 | (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 | |
| 549 | 551 | frame), `controls` (a bindings table in, held actions out), `fullscreen` (the stage on |
| 550 | 552 | the whole screen: whether this browser will, whether it is right now, the way |
| 551 | 553 | in and out, and a refusal swallowed rather than thrown), `escape` (the Escape |
src/client/audio.test.ts +64 −6
| @@ -1,8 +1,20 @@ | ||
| 1 | 1 | 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"; | |
| 4 | 6 | import type { Sound } from "./world.ts"; |
| 5 | 7 | |
| 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 | ||
| 6 | 18 | /** A device that plays nothing and writes down everything. */ |
| 7 | 19 | function recorder(): SoundDevice & { bursts: Burst[]; tones: Tone[]; resumes: number } { |
| 8 | 20 | const device = { |
| @@ -82,8 +94,7 @@ test("your own boots are quieter than somebody else's taken in the same place", | ||
| 82 | 94 | }); |
| 83 | 95 | |
| 84 | 96 | 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) { | |
| 87 | 98 | const device = recorder(); |
| 88 | 99 | const audio = createAudio(device); |
| 89 | 100 | 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 | ||
| 102 | 113 | } |
| 103 | 114 | }); |
| 104 | 115 | |
| 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 | ||
| 105 | 164 | test("the bomb counting is a square tone, and the plant climbs where the defuse falls", () => { |
| 106 | 165 | const device = recorder(); |
| 107 | 166 | 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", | ||
| 196 | 255 | |
| 197 | 256 | // Every recipe places every voice it builds: a name that forgot would leave |
| 198 | 257 | // 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) { | |
| 201 | 259 | const one = recorder(); |
| 202 | 260 | createAudio(one).play(name, [900, 0, 0], [0, 0, 0], false, NORTH); |
| 203 | 261 | const voices = [...one.bursts, ...one.tones]; |
src/client/audio.ts +22
| @@ -104,6 +104,28 @@ const BOMB_CLOSE = 64; | ||
| 104 | 104 | const DEATH_EARSHOT = 400; |
| 105 | 105 | const DEATH_CLOSE = 64; |
| 106 | 106 | |
| 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 | ||
| 107 | 129 | /** The shot a weapon makes: its filter colour, its thump and how long it rings. */ |
| 108 | 130 | export const shotTone = (weapon: WeaponId): { cut: number; thump: number; length: number } => |
| 109 | 131 | 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 | ||
| 339 | 339 | |
| 340 | 340 | ### sound |
| 341 | 341 | |
| 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. | |
| 343 | 343 | |
| 344 | 344 | `createSoundDevice` gives a `SoundDevice` that opens its `AudioContext` on the |
| 345 | 345 | 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. | ||
| 353 | 353 | |
| 354 | 354 | The device is positional, in one dimension. Both `Burst` and `Tone` take an |
| 355 | 355 | 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. | |
| 357 | 357 | `panOf` is the arithmetic that works one out, from a source, an ear and the |
| 358 | 358 | heading the ear faces. That heading is in degrees, the same angle |
| 359 | 359 | `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 | ||
| 364 | 364 | be: two speakers say left and right, and the game does not pay for a head model |
| 365 | 365 | to pretend otherwise. |
| 366 | 366 | |
| 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 | |
| 368 | 382 | used — a NaN or a number outside −1..1 is clamped to centre, never handed to a |
| 369 | 383 | node — and a voice with **no** pan at all builds no panner and is wired exactly |
| 370 | 384 | as it was before panning existed, which is what the range does today. And a |
| 371 | 385 | 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 | |
| 373 | 387 | throwing, so a fake that never grew the method plays the sound centred rather |
| 374 | 388 | than dropping the frame. |
| 375 | 389 |
src/engine/guide.test.ts +4 −2
| @@ -61,10 +61,12 @@ const BARE = /^[A-Za-z_$][A-Za-z0-9_$]*$/; | ||
| 61 | 61 | * this file ignores without needing to be told about it. |
| 62 | 62 | */ |
| 63 | 63 | const NOT_THE_ENGINES = new Set([ |
| 64 | "AudioContext", "Float32Array", "Float64Array", "Int32Array", "StereoPannerNode", | |
| 64 | "AudioContext", "DynamicsCompressorNode", "Float32Array", "Float64Array", "GainNode", | |
| 65 | "Int32Array", "StereoPannerNode", | |
| 65 | 66 | "TEXTURE_2D_ARRAY", |
| 66 | 67 | "addEventListener", |
| 67 | "cancelAnimationFrame", "createStereoPanner", "de_forge", "fullscreenElement", | |
| 68 | "cancelAnimationFrame", "createDynamicsCompressor", "createStereoPanner", "de_forge", | |
| 69 | "fullscreenElement", | |
| 68 | 70 | "pointerLockElement", |
| 69 | 71 | "removeEventListener", "requestAnimationFrame", "uniformMatrix4fv", |
| 70 | 72 | ]); |
src/engine/sound.test.ts +203 −23
| @@ -1,5 +1,8 @@ | ||
| 1 | 1 | 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"; | |
| 3 | 6 | |
| 4 | 7 | // --- the arithmetic ------------------------------------------------------- |
| 5 | 8 | |
| @@ -77,47 +80,78 @@ test("panOf never leaves -1..1, and nonsense in is centre out", () => { | ||
| 77 | 80 | |
| 78 | 81 | type Event = { kind: string; at: number; value: number; time: number }; |
| 79 | 82 | |
| 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 | ||
| 80 | 89 | /** |
| 81 | 90 | * The smallest thing that answers like an AudioContext, and remembers. |
| 82 | 91 | * `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. | |
| 84 | 99 | */ |
| 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; | |
| 88 | 103 | } { |
| 89 | 104 | const log: Event[] = []; |
| 105 | const edges: Edge[] = []; | |
| 106 | const nodes: Made[] = []; | |
| 90 | 107 | const buffers: Float32Array[] = []; |
| 91 | 108 | /** The `pan` of every `StereoPannerNode` this context handed out, in order. */ |
| 92 | 109 | const panners: { value: number }[] = []; |
| 93 | const state = { contexts: 0, resumes: 0 }; | |
| 110 | const state = { contexts: 0, resumes: 0, made: 0 }; | |
| 94 | 111 | const param = (kind: string) => ({ |
| 95 | 112 | value: 0, |
| 96 | 113 | setValueAtTime: (value: number, time: number) => log.push({ kind: `${kind}.set`, at: 0, value, time }), |
| 97 | 114 | exponentialRampToValueAtTime: (value: number, time: number) => |
| 98 | 115 | log.push({ kind: `${kind}.ramp`, at: 0, value, time }), |
| 99 | 116 | }); |
| 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 | }; | |
| 116 | 150 | class Fake { |
| 117 | 151 | sampleRate = 48000; |
| 118 | 152 | currentTime = 2; |
| 119 | 153 | state = "suspended"; |
| 120 | destination = { kind: "destination" }; | |
| 154 | destination = { kind: "destination", id: "destination" }; | |
| 121 | 155 | constructor() { |
| 122 | 156 | state.contexts += 1; |
| 123 | 157 | } |
| @@ -142,9 +176,15 @@ function fakeContext(options: { panner?: boolean } = {}): { | ||
| 142 | 176 | return made; |
| 143 | 177 | }; |
| 144 | 178 | } |
| 179 | if (options.compressor !== false) { | |
| 180 | (Fake.prototype as { createDynamicsCompressor?: () => unknown }).createDynamicsCompressor = () => | |
| 181 | node("limiter"); | |
| 182 | } | |
| 145 | 183 | return { |
| 146 | 184 | Ctor: Fake as unknown as Ctor, |
| 147 | 185 | log, |
| 186 | edges, | |
| 187 | nodes, | |
| 148 | 188 | buffers, |
| 149 | 189 | panners, |
| 150 | 190 | get contexts() { return state.contexts; }, |
| @@ -154,6 +194,25 @@ function fakeContext(options: { panner?: boolean } = {}): { | ||
| 154 | 194 | |
| 155 | 195 | const find = (log: Event[], kind: string): Event | undefined => log.find((e) => e.kind === kind); |
| 156 | 196 | |
| 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 | ||
| 157 | 216 | test("a device with no AudioContext at all stays quiet instead of throwing", () => { |
| 158 | 217 | const device = createSoundDevice(undefined); |
| 159 | 218 | device.resume(); |
| @@ -220,6 +279,127 @@ test("a muted device builds no nodes at all", () => { | ||
| 220 | 279 | expect(fake.log.length).toBeGreaterThan(0); |
| 221 | 280 | }); |
| 222 | 281 | |
| 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 | ||
| 223 | 403 | // --- panning, and the browser that cannot --------------------------------- |
| 224 | 404 | |
| 225 | 405 | 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 @@ | ||
| 3 | 3 | * through a resonant filter, which is anything that hits or explodes, and a |
| 4 | 4 | * tone that can slide, which is anything that beeps or thumps. Both are shaped |
| 5 | 5 | * 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. | |
| 7 | 14 | * |
| 8 | 15 | * The device is positional in exactly one dimension: a voice may carry a `pan`, |
| 9 | 16 | * −1 hard left to +1 hard right, and `panOf` is the arithmetic that works one |
| @@ -103,26 +110,129 @@ const panValue = (pan: number): number => | ||
| 103 | 110 | |
| 104 | 111 | const NOISE_SECONDS = 0.5; |
| 105 | 112 | |
| 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 | ||
| 106 | 182 | const defaultCtor = (): Ctor | undefined => { |
| 107 | 183 | const g = globalThis as unknown as { AudioContext?: Ctor; webkitAudioContext?: Ctor }; |
| 108 | 184 | return g.AudioContext ?? g.webkitAudioContext; |
| 109 | 185 | }; |
| 110 | 186 | |
| 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 | } | |
| 114 | 204 | |
| 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) { | |
| 118 | 219 | 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); | |
| 121 | 222 | const data = noise.getChannelData(0); |
| 122 | 223 | 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 }; | |
| 123 | 233 | } |
| 124 | if (ctx.state === "suspended") void ctx.resume(); | |
| 125 | return ctx; | |
| 234 | if (wiring.context.state === "suspended") void wiring.context.resume(); | |
| 235 | return wiring; | |
| 126 | 236 | }; |
| 127 | 237 | |
| 128 | 238 | /** A gain node that decays from `gain` to `floor` over `length` seconds. */ |
| @@ -134,19 +244,19 @@ export function createSoundDevice(Ctor: Ctor | undefined = defaultCtor()): Sound | ||
| 134 | 244 | }; |
| 135 | 245 | |
| 136 | 246 | /** |
| 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. | |
| 142 | 252 | */ |
| 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; | |
| 145 | 255 | const make = (context as Partial<AudioContext>).createStereoPanner; |
| 146 | if (typeof make !== "function") return context.destination; | |
| 256 | if (typeof make !== "function") return bus; | |
| 147 | 257 | const panner = make.call(context); |
| 148 | 258 | panner.pan.value = panValue(pan); |
| 149 | panner.connect(context.destination); | |
| 259 | panner.connect(bus); | |
| 150 | 260 | return panner; |
| 151 | 261 | }; |
| 152 | 262 | |
| @@ -156,8 +266,9 @@ export function createSoundDevice(Ctor: Ctor | undefined = defaultCtor()): Sound | ||
| 156 | 266 | |
| 157 | 267 | noiseBurst({ frequency, gain, length, q = 0.9, rate = 1, pan }) { |
| 158 | 268 | 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; | |
| 161 | 272 | const now = context.currentTime; |
| 162 | 273 | const source = context.createBufferSource(); |
| 163 | 274 | source.buffer = noise; |
| @@ -166,15 +277,16 @@ export function createSoundDevice(Ctor: Ctor | undefined = defaultCtor()): Sound | ||
| 166 | 277 | filter.type = "bandpass"; |
| 167 | 278 | filter.frequency.value = frequency; |
| 168 | 279 | 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)); | |
| 170 | 281 | source.start(now); |
| 171 | 282 | source.stop(now + length); |
| 172 | 283 | }, |
| 173 | 284 | |
| 174 | 285 | tone({ frequency, gain, length, slideTo, type = "sine", floor = 0.0008, tail = 0, pan }) { |
| 175 | 286 | 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; | |
| 178 | 290 | const now = context.currentTime; |
| 179 | 291 | const osc = context.createOscillator(); |
| 180 | 292 | osc.type = type; |
| @@ -183,7 +295,7 @@ export function createSoundDevice(Ctor: Ctor | undefined = defaultCtor()): Sound | ||
| 183 | 295 | osc.frequency.setValueAtTime(frequency, now); |
| 184 | 296 | osc.frequency.exponentialRampToValueAtTime(slideTo, now + length); |
| 185 | 297 | } |
| 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)); | |
| 187 | 299 | osc.start(now); |
| 188 | 300 | osc.stop(now + length + tail); |
| 189 | 301 | }, |
src/range/audio.test.ts +81 −6
| @@ -18,13 +18,22 @@ type Event = { kind: string; value: number; time: number }; | ||
| 18 | 18 | |
| 19 | 19 | type Param = { value: number }; |
| 20 | 20 | |
| 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]; | |
| 22 | 28 | |
| 23 | 29 | /** 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 | } { | |
| 25 | 33 | const log: Event[] = []; |
| 26 | 34 | const nodes: Node[] = []; |
| 27 | const state = { contexts: 0 }; | |
| 35 | const edges: Edge[] = []; | |
| 36 | const state = { contexts: 0, made: 0 }; | |
| 28 | 37 | const param = (kind: string): Param => ({ |
| 29 | 38 | value: 0, |
| 30 | 39 | 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 | ||
| 32 | 41 | log.push({ kind: `${kind}.ramp`, value, time }), |
| 33 | 42 | }) as Param; |
| 34 | 43 | const node = (kind: string): Node => { |
| 44 | const id = `${kind}${(state.made += 1)}`; | |
| 35 | 45 | const made = { |
| 36 | 46 | kind, |
| 47 | id, | |
| 37 | 48 | type: "", |
| 38 | 49 | buffer: null as unknown, |
| 39 | 50 | pan: param(`${kind}.pan`), |
| @@ -41,7 +52,17 @@ function fakeAudio(): { Ctor: Ctor; log: Event[]; nodes: Node[]; readonly contex | ||
| 41 | 52 | playbackRate: param(`${kind}.rate`), |
| 42 | 53 | gain: param(`${kind}.gain`), |
| 43 | 54 | 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 | }, | |
| 45 | 66 | start: (time: number) => log.push({ kind: `${kind}.start`, value: 0, time }), |
| 46 | 67 | stop: (time: number) => log.push({ kind: `${kind}.stop`, value: 0, time }), |
| 47 | 68 | }; |
| @@ -52,7 +73,7 @@ function fakeAudio(): { Ctor: Ctor; log: Event[]; nodes: Node[]; readonly contex | ||
| 52 | 73 | sampleRate = 48000; |
| 53 | 74 | currentTime = 0; |
| 54 | 75 | state = "running"; |
| 55 | destination = { kind: "destination" }; | |
| 76 | destination = { kind: "destination", id: "destination" }; | |
| 56 | 77 | constructor() { |
| 57 | 78 | state.contexts += 1; |
| 58 | 79 | } |
| @@ -70,21 +91,28 @@ function fakeAudio(): { Ctor: Ctor; log: Event[]; nodes: Node[]; readonly contex | ||
| 70 | 91 | // — but a double that answers less than the thing it doubles is how a |
| 71 | 92 | // thrown frame hides until the day somebody adds the argument. |
| 72 | 93 | 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"); } | |
| 73 | 97 | } |
| 74 | 98 | return { |
| 75 | 99 | Ctor: Fake as unknown as Ctor, |
| 76 | 100 | log, |
| 77 | 101 | nodes, |
| 102 | edges, | |
| 78 | 103 | get contexts() { return state.contexts; }, |
| 79 | 104 | }; |
| 80 | 105 | } |
| 81 | 106 | |
| 82 | 107 | /** 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 | } { | |
| 84 | 111 | const fake = fakeAudio(); |
| 85 | 112 | return { |
| 86 | 113 | log: fake.log, |
| 87 | 114 | nodes: fake.nodes, |
| 115 | edges: fake.edges, | |
| 88 | 116 | get contexts() { return fake.contexts; }, |
| 89 | 117 | voice: createRangeAudio(createSoundDevice(fake.Ctor)), |
| 90 | 118 | }; |
| @@ -183,6 +211,53 @@ test("the clock running out is one falling tone, once each time it is asked for" | ||
| 183 | 211 | expect(length(a.log, "osc")).toBeCloseTo(OVER.length, 9); |
| 184 | 212 | }); |
| 185 | 213 | |
| 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 | ||
| 186 | 261 | // --- the arithmetic of the room ------------------------------------------- |
| 187 | 262 | |
| 188 | 263 | 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 { | ||
| 122 | 122 | frequency: param(() => {}), |
| 123 | 123 | playbackRate: param(() => {}), |
| 124 | 124 | 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(() => {}), | |
| 125 | 133 | gain: param((value) => void (state.gain = value)), |
| 126 | 134 | connect: (next: unknown) => next, |
| 127 | 135 | start: () => |
| @@ -151,6 +159,11 @@ export function audioCounter(): FakeAudio { | ||
| 151 | 159 | // — but a double that answers less than the thing it doubles is how a |
| 152 | 160 | // thrown frame hides until the day somebody adds the argument. |
| 153 | 161 | 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"); } | |
| 154 | 167 | } |
| 155 | 168 | return { |
| 156 | 169 | get contexts() { return state.contexts; }, |