Public: anyone can read this; the crew can change it.

forked from trevato/cs

One AudioContext fake, behind the door: the sound device's test double stops being written three times #369

Merged opened this

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

Merged

torque/cs-engine:torque/332-one-audiocontext-fake-behind-the-door-th → main 1 commit fork

  • Merged. torque/332-one-audiocontext-fake-behind-the-door-th was merged into main as 79445d9.

Review

Sign in to review this change.
8 files changed +508 −307
src/engine/GUIDE.md +47 −3
@@ -150,7 +150,7 @@ const SPEED = 4, TURN = 120, EYE = 1.6, REACH = 40;
150150
151151 ## What is behind the door
152152
153 **Weight**: 45.8 kB raw, 18.3 kB gzipped — every group at once, which is the most any game can pay.
153 **Weight**: 47.4 kB raw, 18.9 kB gzipped — every group at once, which is the most any game can pay.
154154
155155 Every section below opens with what that group costs a bundle, because "what
156156 does it cost to use?" is a question a page can answer with a number. The number
@@ -387,6 +387,13 @@ double in this repository — connects straight to the bus instead of
387387 throwing, so a fake that never grew the method plays the sound centred rather
388388 than dropping the frame.
389389
390 What a game gets for its tests is the context itself: `fakeAudioContext`, the
391 double under *context* below, is the `AudioContext` this device is tested
392 against, and a game hands its `AudioRecorder.Ctor` to `createSoundDevice` — or
393 installs it as the page's `AudioContext` — and reads back every voice it asked
394 for. Both of this device's fallbacks are options on it, so a game's own suite
395 can be the oldest Safari in one line.
396
390397 ### loop
391398
392399 **Weight**: 0.6 kB raw, 0.4 kB gzipped — this module alone.
@@ -884,7 +891,8 @@ you draw through `overlay` draws onto one.
884891 What it deliberately does not know is what a renderer or a sound device is.
885892 `FakeCanvas.gl` is an empty slot that `getContext("webgl2")` hands back, and what
886893 you put in it is the engine's too: `fakeGl()`, the renderer's double, which the
887 next section is about. What stays yours is an `AudioContext`, a `window.fetch`,
894 next section is about, and the `AudioContext` your sound device opens is
895 `fakeAudioContext()`, the one after it. What stays yours is a `window.fetch`,
888896 and installing any of this as a global — a page that knew about those would be
889897 one game's page wearing the engine's name. Your game's half lives beside your
890898 entry: `src/client/stage.test-helper.ts` and `src/range/stage.test-helper.ts`
@@ -920,6 +928,42 @@ Like `fakePage`, it ships in no bundle: nothing a game bundles imports it, and
920928 `gl.test.ts` reads every built bundle to say so. Every game here boots on it —
921929 the shooter, the range and the example each put one in their scene canvas.
922930
931 ### context
932
933 **Weight**: 1.7 kB raw, 0.9 kB gzipped — this module alone.
934
935 The third: an `AudioContext` for the sound device to open, when there is no
936 speaker. `fakeAudioContext` hands back an `AudioRecorder` whose
937 `AudioRecorder.Ctor` is the constructor to give `createSoundDevice`. It records
938 once, completely. `AudioRecorder.log` is every `AudioEvent` in order — a param
939 assigned (`panner.pan.value`), set or ramped at a time (`gain.gain.set`,
940 `gain.gain.ramp`), a connect and the node it reached, a start and a stop with
941 their scheduled times — each naming the node it happened to. Every `FakeAudioNode`
942 it handed out is in `AudioRecorder.nodes`, numbered in the order it was made
943 (gain1, limiter2, …) with every `FakeParam` the device sets, and the buffers
944 it filled are in `AudioRecorder.buffers`. `AudioRecorder.edges` and
945 `AudioRecorder.scheduled` are that log, read: the graph as node→node pairs, and
946 only the events that make a sound. A game folds what it needs out of the same
947 log rather than writing a second fake — the range's boot tests read one row a
948 voice, with its note, its peak gain and its side, from
949 `src/range/stage.test-helper.ts`.
950
951 `AudioContextOptions` says what the context is before anything is asked of it.
952 `AudioContextOptions.panner` and `AudioContextOptions.compressor` default to true; false takes `createStereoPanner` or
953 `createDynamicsCompressor` off the context entirely, because a browser without
954 one has no method rather than one that answers null. The clock opens at 2
955 seconds, not zero, so a voice scheduled at 0 rather than at `AudioContext.currentTime` shows
956 in the times, and the state opens `"suspended"`, which is what makes the device
957 call `resume()` — the numbers `sound.test.ts` is read against. The range passes
958 0 and `"running"`.
959
960 It plays nothing and checks no sample: a param's value is what was last
961 assigned to it, and scheduling one writes it down and moves nothing. Every node
962 connects to exactly the one thing it was given, so a voice's route to the
963 destination is a path a test walks. `context.test.ts` holds it to the members
964 `sound.ts` reads off a context, and reads every built bundle to say it ships
965 in none of them.
966
923967 ### digest
924968
925969 **Weight**: 1.5 kB raw, 0.9 kB gzipped — this module alone.
@@ -1067,7 +1111,7 @@ rows, an entry aside, and issue #130 is where it gets one.
10671111 | the game supplies | the shooter | the range |
10681112 | --- | --- | --- |
10691113 | an entry | `src/client/main.ts` | `src/range/main.ts` |
1070 | a host | `fakePage` and `fakeGl` plus the ids, the sound, the renderer and the posts in `src/client/stage.test-helper.ts` | `fakePage` and `fakeGl` plus the ids and the `AudioContext` in `src/range/stage.test-helper.ts` |
1114 | a host | `fakePage` and `fakeGl` plus the ids, the sound, the renderer and the posts in `src/client/stage.test-helper.ts` | `fakePage`, `fakeGl` and `fakeAudioContext` plus the ids and the voices in `src/range/stage.test-helper.ts` |
10711115 | a bindings table | `src/client/binding.ts` — buy, scoreboard, weapon slots | `src/range/binding.ts` — a trigger and a start, nothing else |
10721116 | a tick rate | 64 Hz | 50 Hz |
10731117 | mesh recipes | `src/client/models.ts` | `src/range/level.ts` — a room and a plate |
src/engine/context.test.ts added +140
@@ -0,0 +1,140 @@
1 /**
2 * The fake `AudioContext`, held to the device it doubles for. It answers every
3 * member `sound.ts` reads off a context, its options take away exactly the two
4 * methods a real browser may lack, and everything it writes down is in one log
5 * that the other views are read from. The last two tests say no bundle a
6 * browser downloads carries a byte of it.
7 */
8
9 import { expect, test } from "bun:test";
10 import { readdirSync, readFileSync } from "node:fs";
11 import { join, relative } from "node:path";
12 import { builtBundles } from "./bundles.test.ts";
13 import { AUDIO_PARAMS, fakeAudioContext, RECORDER } from "./context.ts";
14 import { createSoundDevice } from "./sound.ts";
15
16 const ENGINE = import.meta.dir;
17
18 /** A source with its comments taken out, so a name in a docstring is not a use. */
19 const code = (file: string, dir = ENGINE): string =>
20 readFileSync(join(dir, file), "utf8").replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, "");
21
22 /** Every member `sound.ts` reads off a context, and every `create…` it calls however it reaches one. */
23 function contextNames(source: string): string[] {
24 const names = new Set<string>();
25 for (const match of source.matchAll(/\bcontext\.([A-Za-z]+)/g)) names.add(match[1] as string);
26 for (const match of source.matchAll(/\.(create[A-Z][A-Za-z]*)\b/g)) names.add(match[1] as string);
27 return [...names].sort();
28 }
29
30 test("the fake answers every context member the device uses", () => {
31 const used = contextNames(code("sound.ts"));
32 // The reader is alive: it found the two a browser may lack as well as the rest.
33 expect(used).toContain("createStereoPanner");
34 expect(used).toContain("createDynamicsCompressor");
35 expect(used).toContain("currentTime");
36 const context = new (fakeAudioContext().Ctor)() as unknown as Record<string, unknown>;
37 expect(used.filter((name) => context[name] === undefined)).toEqual([]);
38 });
39
40 test("the defaults are the context sound.test.ts is read against: a clock at 2, suspended", () => {
41 const context = new (fakeAudioContext().Ctor)();
42 expect(context.currentTime).toBe(2);
43 expect(context.state).toBe("suspended");
44 expect(context.sampleRate).toBe(48000);
45 const running = new (fakeAudioContext({ currentTime: 0, state: "running" }).Ctor)();
46 expect(running.currentTime).toBe(0);
47 expect(running.state).toBe("running");
48 });
49
50 test("a browser without a panner or a compressor has no method at all, not one that answers null", () => {
51 const bare = new (fakeAudioContext({ panner: false, compressor: false }).Ctor)();
52 expect("createStereoPanner" in bare).toBe(false);
53 expect("createDynamicsCompressor" in bare).toBe(false);
54 const full = new (fakeAudioContext().Ctor)();
55 expect(typeof full.createStereoPanner).toBe("function");
56 expect(typeof full.createDynamicsCompressor).toBe("function");
57 // Two recorders share nothing: the option took the method off that one class.
58 expect(typeof new (fakeAudioContext().Ctor)().createStereoPanner).toBe("function");
59 });
60
61 test("one burst, recorded once: every event with its node, its value and its time", () => {
62 const fake = fakeAudioContext();
63 createSoundDevice(fake.Ctor).noiseBurst({ frequency: 1500, gain: 0.35, length: 0.2, pan: -0.5 });
64 expect(fake.contexts).toBe(1);
65 expect(fake.resumes).toBe(1);
66 expect(fake.buffers.map((data) => data.length)).toEqual([24000]);
67 expect(fake.nodes.map((made) => made.id)).toEqual(["gain1", "limiter2", "source3", "filter4", "gain5", "panner6"]);
68 // By hand, from sound.ts: the bus and the limiter as the context opens, then
69 // the voice at the clock's 2 s — rate, colour, envelope, place, start, stop.
70 const at = (kind: string, node: string, value: number, time: number, to = "") => ({ kind, node, value, time, to });
71 expect(fake.log).toEqual([
72 at("gain.gain.value", "gain1", 0.6, 2),
73 at("limiter.threshold.value", "limiter2", -4, 2),
74 at("limiter.knee.value", "limiter2", 6, 2),
75 at("limiter.ratio.value", "limiter2", 12, 2),
76 at("limiter.attack.value", "limiter2", 0.003, 2),
77 at("limiter.release.value", "limiter2", 0.25, 2),
78 at("gain.connect", "gain1", 0, 2, "limiter2"),
79 at("limiter.connect", "limiter2", 0, 2, "destination"),
80 at("source.playbackRate.value", "source3", 1, 2),
81 at("filter.frequency.value", "filter4", 1500, 2),
82 at("filter.Q.value", "filter4", 0.9, 2),
83 // A chain calls each connect before it builds the next node it hands on.
84 at("source.connect", "source3", 0, 2, "filter4"),
85 at("gain.gain.set", "gain5", 0.35, 2),
86 at("gain.gain.ramp", "gain5", 0.0008, 2.2),
87 at("filter.connect", "filter4", 0, 2, "gain5"),
88 at("panner.pan.value", "panner6", -0.5, 2),
89 at("panner.connect", "panner6", 0, 2, "gain1"),
90 at("gain.connect", "gain5", 0, 2, "panner6"),
91 at("source.start", "source3", 0, 2),
92 at("source.stop", "source3", 0, 2.2),
93 ]);
94 // Every other view is that list, read.
95 expect(fake.edges).toEqual([
96 ["gain1", "limiter2"], ["limiter2", "destination"], ["source3", "filter4"],
97 ["filter4", "gain5"], ["panner6", "gain1"], ["gain5", "panner6"],
98 ]);
99 expect(fake.scheduled.map((e) => e.kind)).toEqual(["gain.gain.set", "gain.gain.ramp", "source.start", "source.stop"]);
100 // Every node connects to exactly one thing, so a voice's route is a path.
101 const from = fake.edges.map(([node]) => node);
102 expect(new Set(from).size).toBe(from.length);
103 });
104
105 test("a param is what was last assigned, and scheduling one moves nothing", () => {
106 const fake = fakeAudioContext();
107 const node = (new (fake.Ctor)() as unknown as { createGain(): (typeof fake.nodes)[number] }).createGain();
108 expect(Object.keys(node.params)).toEqual([...AUDIO_PARAMS]);
109 node.gain.setValueAtTime(0.5, 3).exponentialRampToValueAtTime(0.01, 4);
110 expect(node.gain.value).toBe(0);
111 node.gain.value = 0.25;
112 expect(node.params.gain.value).toBe(0.25);
113 expect(fake.log.map((e) => [e.kind, e.value, e.time])).toEqual([
114 ["gain.gain.set", 0.5, 3], ["gain.gain.ramp", 0.01, 4], ["gain.gain.value", 0.25, 2],
115 ]);
116 });
117
118 test("no bundle this tree builds carries the fake", () => {
119 const { dir, bundles } = builtBundles();
120 expect(bundles.length).toBeGreaterThanOrEqual(3);
121 for (const file of bundles) expect(readFileSync(join(dir, file), "utf8")).not.toContain(RECORDER);
122 });
123
124 test("nothing a browser runs names the fake: only tests and the door do", () => {
125 const src = join(ENGINE, "..");
126 const names = readdirSync(src, { recursive: true, encoding: "utf8" })
127 .filter((file) => file.endsWith(".ts") && !file.endsWith(".test.ts") && !file.endsWith(".test-helper.ts"))
128 .filter((file) => code(file, src).includes("fakeAudioContext"))
129 .map((file) => relative(src, join(src, file)))
130 .sort();
131 expect(names).toEqual(["engine/context.ts", "engine/index.ts"]);
132 });
133
134 test("nothing outside the engine writes its own AudioContext double", () => {
135 const src = join(ENGINE, "..");
136 const copies = readdirSync(src, { recursive: true, encoding: "utf8" })
137 .filter((file) => file.endsWith(".ts") && !file.startsWith("engine"))
138 .filter((file) => /createBufferSource\s*\(\s*\)\s*\{/.test(code(file, src)));
139 expect(copies).toEqual([]);
140 });
src/engine/context.ts added +225
@@ -0,0 +1,225 @@
1 /**
2 * The sound device's double: an `AudioContext` that writes down everything it
3 * was asked for and plays nothing. The engine opens the context (`sound.ts`),
4 * so the engine ships the fake one a game's tests hand it — the argument that
5 * put `fakePage` and `fakeGl` behind the door. Before this file there were
6 * three copies of it, one in `sound.test.ts` and two in the range, the same
7 * class each time and differing only in what they kept.
8 *
9 * It records once. `AudioRecorder.log` is every thing a node was asked to do,
10 * in order: a param assigned (`.value =`), set or ramped at a time, a connect
11 * with the node it reached, a start and a stop with the times they were
12 * scheduled for. Every other view is read off that list or off the nodes it
13 * handed out — the graph as node→node `edges`, the `scheduled` events that
14 * make a sound, and whatever a game folds out of it for itself. Nothing is
15 * recorded twice, so no view can disagree with another.
16 *
17 * Every node it hands out is numbered in the order it was made — `gain1`,
18 * `limiter2`, `source3` — and connects to exactly the one node it is given, so
19 * the edges are a graph a test can walk as a path from a voice to the speaker.
20 *
21 * It is a list of what was asked for and must not grow into a synthesiser: a
22 * buffer is an array nobody reads, a param's value is whatever was last
23 * assigned to it — scheduling one writes it down and moves nothing — and no
24 * sample is ever checked.
25 *
26 * It ships in no bundle: nothing a game bundles imports it, and
27 * `context.test.ts` reads every built bundle for the sentence below to say so.
28 */
29
30 import type { Ctor } from "./sound.ts";
31
32 /**
33 * A sentence that appears in this file and in no other. A minifier keeps the
34 * bytes of a string, so `context.test.ts` reads the built bundles for it.
35 */
36 export const RECORDER = "src/engine/context.ts: a double of an AudioContext, in no bundle";
37
38 /** Every param a node in this repository is given, on every node, the limiter's five included. */
39 export const AUDIO_PARAMS = [
40 "pan", "frequency", "playbackRate", "gain", "Q", "threshold", "knee", "ratio", "attack", "release",
41 ] as const;
42
43 type ParamName = (typeof AUDIO_PARAMS)[number];
44
45 /**
46 * What the context is, before anything is asked of it. Each default is the
47 * context `sound.test.ts` holds the device to.
48 */
49 export type AudioContextOptions = {
50 /**
51 * Whether `createStereoPanner` exists as a method at all. Default true. False
52 * is the oldest Safari: no method, rather than one that answers null — the
53 * shape `sound.ts` tests for.
54 */
55 panner?: boolean;
56 /** Whether `createDynamicsCompressor` exists as a method at all. Default true; false as above. */
57 compressor?: boolean;
58 /**
59 * The context clock when it opens, in seconds. Default 2: not zero, so a
60 * voice scheduled at 0 rather than at `currentTime` shows in the times.
61 */
62 currentTime?: number;
63 /**
64 * The state it opens in. Default `"suspended"`, which is what a browser hands
65 * a page and what makes the device call `resume()`; `"running"` never asks.
66 */
67 state?: "suspended" | "running";
68 };
69
70 /** One thing a node was asked to do. */
71 export type AudioEvent = {
72 /**
73 * The node's kind and what was done: `gain.gain.set`, `gain.gain.ramp`,
74 * `panner.pan.value` (assigned), `osc.connect`, `source.start`, `osc.stop`.
75 */
76 kind: string;
77 /** Which node, by the id it was handed out with: `osc4`. */
78 node: string;
79 /** The number asked for; 0 for a connect, a start and a stop. */
80 value: number;
81 /**
82 * When, in context seconds: the scheduled time of a set, a ramp, a start or
83 * a stop, and the clock as it stood for an assignment or a connect.
84 */
85 time: number;
86 /** For a connect, the id of the node it reached, `destination` included; "" otherwise. */
87 to: string;
88 };
89
90 /** A param: its value is what was last assigned, and scheduling is written down. */
91 export type FakeParam = {
92 value: number;
93 setValueAtTime(value: number, time: number): FakeParam;
94 exponentialRampToValueAtTime(value: number, time: number): FakeParam;
95 };
96
97 /** A node the context handed out: which one, its params by name, and the plain properties a device sets. */
98 export type FakeAudioNode = Record<ParamName, FakeParam> & {
99 kind: string;
100 id: string;
101 type: string;
102 buffer: unknown;
103 /** The same params again, as one record, for a test that reads them by name. */
104 params: Record<ParamName, FakeParam>;
105 connect(next: unknown): unknown;
106 start(time: number): void;
107 stop(time: number): void;
108 };
109
110 /** The recorder: a constructor to hand a device, and what everything it opened was asked. */
111 export type AudioRecorder = {
112 /** Hand this to `createSoundDevice`, or install it as `AudioContext`. */
113 Ctor: Ctor;
114 /** Every thing every node was asked to do, in order. The one recording. */
115 log: AudioEvent[];
116 /** Every node handed out, in the order it was made. */
117 nodes: FakeAudioNode[];
118 /** The data of every buffer created, in order. */
119 buffers: Float32Array[];
120 /** Every connect, as the node it left and the node it reached. Read off `log`. */
121 readonly edges: [from: string, to: string][];
122 /** The events that make a sound — sets, ramps, starts and stops — in order. Read off `log`. */
123 readonly scheduled: AudioEvent[];
124 /** Contexts constructed. Zero is a page that has not made a sound. */
125 readonly contexts: number;
126 /** Calls to `resume()`. */
127 readonly resumes: number;
128 };
129
130 const SCHEDULED = /\.(set|ramp|start|stop)$/;
131
132 /** A fresh recorder: no context opened and nothing asked. */
133 export function fakeAudioContext(options: AudioContextOptions = {}): AudioRecorder {
134 const log: AudioEvent[] = [];
135 const nodes: FakeAudioNode[] = [];
136 const buffers: Float32Array[] = [];
137 const count = { contexts: 0, resumes: 0, made: 0 };
138
139 const note = (kind: string, node: string, value: number, time: number, to = ""): void =>
140 void log.push({ kind, node, value, time, to });
141
142 const param = (clock: { currentTime: number }, kind: string, node: string): FakeParam => {
143 let value = 0;
144 const it: FakeParam = {
145 get value() { return value; },
146 set value(next: number) {
147 value = next;
148 note(`${kind}.value`, node, next, clock.currentTime);
149 },
150 setValueAtTime(at: number, time: number) {
151 note(`${kind}.set`, node, at, time);
152 return it;
153 },
154 exponentialRampToValueAtTime(to: number, time: number) {
155 note(`${kind}.ramp`, node, to, time);
156 return it;
157 },
158 };
159 return it;
160 };
161
162 const node = (clock: { currentTime: number }, kind: string): FakeAudioNode => {
163 const id = `${kind}${(count.made += 1)}`;
164 const params = Object.fromEntries(
165 AUDIO_PARAMS.map((name) => [name, param(clock, `${kind}.${name}`, id)]),
166 ) as Record<ParamName, FakeParam>;
167 const made: FakeAudioNode = {
168 kind,
169 id,
170 type: "",
171 buffer: null,
172 params,
173 ...params,
174 connect(next: unknown) {
175 note(`${kind}.connect`, id, 0, clock.currentTime, (next as { id: string }).id);
176 return next;
177 },
178 start: (time: number) => note(`${kind}.start`, id, 0, time),
179 stop: (time: number) => note(`${kind}.stop`, id, 0, time),
180 };
181 nodes.push(made);
182 return made;
183 };
184
185 class Fake {
186 sampleRate = 48000;
187 currentTime = options.currentTime ?? 2;
188 state: string = options.state ?? "suspended";
189 destination = { kind: "destination", id: "destination" };
190 constructor() {
191 count.contexts += 1;
192 }
193 resume(): void {
194 count.resumes += 1;
195 this.state = "running";
196 }
197 createBuffer(_channels: number, length: number) {
198 const data = new Float32Array(length);
199 buffers.push(data);
200 return { length, getChannelData: () => data };
201 }
202 createBufferSource() { return node(this, "source"); }
203 createBiquadFilter() { return node(this, "filter"); }
204 createGain() { return node(this, "gain"); }
205 createOscillator() { return node(this, "osc"); }
206 }
207 type Maybe = { createStereoPanner?: () => FakeAudioNode; createDynamicsCompressor?: () => FakeAudioNode };
208 if (options.panner !== false)
209 (Fake.prototype as Maybe).createStereoPanner = function (this: Fake) { return node(this, "panner"); };
210 if (options.compressor !== false)
211 (Fake.prototype as Maybe).createDynamicsCompressor = function (this: Fake) { return node(this, "limiter"); };
212
213 return {
214 Ctor: Fake as unknown as Ctor,
215 log,
216 nodes,
217 buffers,
218 get edges() {
219 return log.filter((e) => e.to !== "").map((e): [string, string] => [e.node, e.to]);
220 },
221 get scheduled() { return log.filter((e) => SCHEDULED.test(e.kind)); },
222 get contexts() { return count.contexts; },
223 get resumes() { return count.resumes; },
224 };
225 }
src/engine/index.ts +12
@@ -153,6 +153,18 @@ export type {
153153 export { fakeGl } from "./gl.ts";
154154 export type { GlRecorder } from "./gl.ts";
155155
156 /**
157 * The sound device's double: an `AudioContext` that writes down every param
158 * assigned or scheduled, every connect and every start and stop, and plays
159 * nothing. The engine opens the context, so it ships the one a test hands the
160 * device — it was three copies, the engine's and two in the range. Whether it
161 * has a panner and a compressor, its clock and its state are options; every
162 * view a test reads is derived from its one log. Like `fakeGl`, it ships in no
163 * bundle.
164 */
165 export { fakeAudioContext } from "./context.ts";
166 export type { AudioContextOptions, AudioEvent, AudioRecorder, FakeAudioNode, FakeParam } from "./context.ts";
167
156168 /**
157169 * A run folded into a hash: the quantiser, the fold with its checkpoints, and
158170 * the complaint that says where two runs parted. Like `fakePage`, it is a tool
src/engine/sound.test.ts +18 −108
@@ -1,8 +1,12 @@
11 import { expect, test } from "bun:test";
22 import {
33 attenuate, createSoundDevice, HEAD, LIMIT_ATTACK, LIMIT_KNEE, LIMIT_RATIO, LIMIT_RELEASE,
4 LIMIT_THRESHOLD, MASTER_GAIN, panOf, type Ctor,
4 LIMIT_THRESHOLD, MASTER_GAIN, panOf,
55 } from "./sound.ts";
6 import {
7 fakeAudioContext, type AudioContextOptions, type AudioEvent, type AudioRecorder, type FakeAudioNode,
8 type FakeParam,
9 } from "./context.ts";
610
711 // --- the arithmetic -------------------------------------------------------
812
@@ -78,121 +82,27 @@ test("panOf never leaves -1..1, and nonsense in is centre out", () => {
7882
7983 // --- the wiring, against a context that only writes down what it was asked --
8084
81 type Event = { kind: string; at: number; value: number; time: number };
82
8385 /** One `connect` the device made, as the node it left and the node it reached. */
8486 type Edge = [from: string, to: string];
8587
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
8988 /**
90 * The smallest thing that answers like an AudioContext, and remembers.
89 * The engine's own recorder (`context.ts`), at its defaults: a clock at 2 and a
90 * suspended context, which are the numbers every time below is read against.
9191 * `panner: false` is a context without `createStereoPanner` — the oldest
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.
92 * Safari; `compressor: false` is one without `createDynamicsCompressor`, which
93 * is the same browser and the path the bus falls back to. `panners` is the pan
94 * of every `StereoPannerNode` it handed out, in order, read off its nodes.
9995 */
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;
103 } {
104 const log: Event[] = [];
105 const edges: Edge[] = [];
106 const nodes: Made[] = [];
107 const buffers: Float32Array[] = [];
108 /** The `pan` of every `StereoPannerNode` this context handed out, in order. */
109 const panners: { value: number }[] = [];
110 const state = { contexts: 0, resumes: 0, made: 0 };
111 const param = (kind: string) => ({
112 value: 0,
113 setValueAtTime: (value: number, time: number) => log.push({ kind: `${kind}.set`, at: 0, value, time }),
114 exponentialRampToValueAtTime: (value: number, time: number) =>
115 log.push({ kind: `${kind}.ramp`, at: 0, value, time }),
116 });
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 };
150 class Fake {
151 sampleRate = 48000;
152 currentTime = 2;
153 state = "suspended";
154 destination = { kind: "destination", id: "destination" };
155 constructor() {
156 state.contexts += 1;
157 }
158 resume(): void {
159 state.resumes += 1;
160 this.state = "running";
161 }
162 createBuffer(_channels: number, length: number) {
163 const data = new Float32Array(length);
164 buffers.push(data);
165 return { length, getChannelData: () => data };
166 }
167 createBufferSource() { return node("source"); }
168 createBiquadFilter() { return node("filter"); }
169 createGain() { return node("gain"); }
170 createOscillator() { return node("osc"); }
171 }
172 if (options.panner !== false) {
173 (Fake.prototype as { createStereoPanner?: () => unknown }).createStereoPanner = () => {
174 const made = node("panner");
175 panners.push(made.pan);
176 return made;
177 };
178 }
179 if (options.compressor !== false) {
180 (Fake.prototype as { createDynamicsCompressor?: () => unknown }).createDynamicsCompressor = () =>
181 node("limiter");
182 }
183 return {
184 Ctor: Fake as unknown as Ctor,
185 log,
186 edges,
187 nodes,
188 buffers,
189 panners,
190 get contexts() { return state.contexts; },
191 get resumes() { return state.resumes; },
192 };
96 function fakeContext(options: AudioContextOptions = {}): AudioRecorder & { readonly panners: FakeParam[] } {
97 const fake = fakeAudioContext(options);
98 return Object.defineProperty(fake, "panners", {
99 get: () => fake.nodes.filter((made) => made.kind === "panner").map((made) => made.pan),
100 }) as AudioRecorder & { readonly panners: FakeParam[] };
193101 }
194102
195 const find = (log: Event[], kind: string): Event | undefined => log.find((e) => e.kind === kind);
103 type Made = FakeAudioNode;
104
105 const find = (log: AudioEvent[], kind: string): AudioEvent | undefined => log.find((e) => e.kind === kind);
196106
197107 /**
198108 * Where a node's sound goes, one hop at a time, starting with the node itself.
src/engine/surface.json +7 −1
@@ -1,8 +1,11 @@
11 {
2 "version": "0.9.0",
2 "version": "0.10.0",
33 "names": [
44 "Activations",
55 "Adam",
6 "AudioContextOptions",
7 "AudioEvent",
8 "AudioRecorder",
69 "Body",
710 "Box",
811 "Builder",
@@ -20,12 +23,14 @@
2023 "Effects",
2124 "Escape",
2225 "EscapeHost",
26 "FakeAudioNode",
2327 "FakeCanvas",
2428 "FakeDocument",
2529 "FakeElement",
2630 "FakeKeyboard",
2731 "FakePage",
2832 "FakePaint",
33 "FakeParam",
2934 "FakeTarget",
3035 "FakeWindow",
3136 "Fold",
@@ -128,6 +133,7 @@
128133 "effectAt",
129134 "expireEffects",
130135 "eyeHeight",
136 "fakeAudioContext",
131137 "fakeGl",
132138 "fakePage",
133139 "fbm",
src/range/audio.test.ts +18 −100
@@ -1,9 +1,7 @@
11 /**
2 * The range's five sounds, against a fake `AudioContext` of this test's own.
3 * It is the same shape `src/engine/sound.test.ts` drives the device with — a
4 * class that builds nodes which write down what they were asked for — written
5 * again here rather than imported, because that fake is that test's business
6 * and this one wants to read nodes as well as calls.
2 * The range's five sounds, against the engine's fake `AudioContext` —
3 * `fakeAudioContext`, the one `src/engine/sound.test.ts` drives the device
4 * with, which builds nodes that write down what they were asked for.
75 *
86 * Nothing here plays anything. What is checked is the recipe: how many voices
97 * a verb asks for, at what gain, for how long, that distance is in the gain at
@@ -11,120 +9,40 @@
119 */
1210
1311 import { expect, test } from "bun:test";
14 import { createSoundDevice, type Ctor, type Vec3 } from "../engine/index.ts";
12 import {
13 createSoundDevice, fakeAudioContext, type AudioEvent, type Ctor, type FakeAudioNode, type Vec3,
14 } from "../engine/index.ts";
1515 import {
1616 CRACK, HIT, LANE_PAN, OVER, POP, TICK, createRangeAudio, loudness, sideOf, type RangeAudio,
1717 } from "./audio.ts";
1818
19 type Event = { kind: string; value: number; time: number };
19 type Event = AudioEvent;
2020
21 type Param = { value: number };
22
23 type Node = {
24 kind: string; id: string; type: string; pan: Param; frequency: Param; gain: Param; Q: Param;
25 threshold: Param; knee: Param; ratio: Param; attack: Param; release: Param;
26 };
21 type Node = FakeAudioNode;
2722
2823 /** One `connect` the device made: the node it left, and the node it reached. */
2924 type Edge = [from: string, to: string];
3025
3126 /**
32 * The smallest thing that answers like an `AudioContext`, and remembers.
27 * The range's voice over a device over the engine's fake context, and what it
28 * wrote. The clock is zero and the context is already running, so a time here
29 * is a length and the device never has to wake it. `log` is the recorder's
30 * scheduled events — the sets, ramps, starts and stops that make a sound —
31 * because a sound is what these tests read; the bus the device wires as it
32 * opens is in `nodes` and `edges`.
3333 *
3434 * `panner: false` is a context with no `createStereoPanner` on it at all: the
3535 * oldest Safari, which the engine's device answers by sending the voice
36 * straight to the bus. The range has to keep playing on it, so this fake can
37 * be one.
36 * straight to the bus. The range has to keep playing on it.
3837 */
39 function fakeAudio(options: { panner?: boolean } = {}): {
40 Ctor: Ctor; log: Event[]; nodes: Node[]; edges: Edge[]; readonly contexts: number;
41 } {
42 const log: Event[] = [];
43 const nodes: Node[] = [];
44 const edges: Edge[] = [];
45 const state = { contexts: 0, made: 0 };
46 const param = (kind: string): Param => ({
47 value: 0,
48 setValueAtTime: (value: number, time: number) => log.push({ kind: `${kind}.set`, value, time }),
49 exponentialRampToValueAtTime: (value: number, time: number) =>
50 log.push({ kind: `${kind}.ramp`, value, time }),
51 }) as Param;
52 const node = (kind: string): Node => {
53 const id = `${kind}${(state.made += 1)}`;
54 const made = {
55 kind,
56 id,
57 type: "",
58 buffer: null as unknown,
59 pan: param(`${kind}.pan`),
60 frequency: param(`${kind}.frequency`),
61 playbackRate: param(`${kind}.rate`),
62 gain: param(`${kind}.gain`),
63 Q: param(`${kind}.q`),
64 // The limiter's five knobs. The range sets none of them itself — the
65 // engine's bus does — and a double without them is a thrown frame.
66 threshold: param(`${kind}.threshold`),
67 knee: param(`${kind}.knee`),
68 ratio: param(`${kind}.ratio`),
69 attack: param(`${kind}.attack`),
70 release: param(`${kind}.release`),
71 connect: (next: unknown) => {
72 edges.push([id, (next as { id?: string }).id ?? "destination"]);
73 return next;
74 },
75 start: (time: number) => log.push({ kind: `${kind}.start`, value: 0, time }),
76 stop: (time: number) => log.push({ kind: `${kind}.stop`, value: 0, time }),
77 };
78 nodes.push(made as unknown as Node);
79 return made as unknown as Node;
80 };
81 class Fake {
82 sampleRate = 48000;
83 currentTime = 0;
84 state = "running";
85 destination = { kind: "destination", id: "destination" };
86 constructor() {
87 state.contexts += 1;
88 }
89 resume(): void {}
90 createBuffer(_channels: number, length: number) {
91 const data = new Float32Array(length);
92 return { length, getChannelData: () => data };
93 }
94 createBufferSource() { return node("source"); }
95 createBiquadFilter() { return node("filter"); }
96 createGain() { return node("gain"); }
97 createOscillator() { return node("osc"); }
98 // Where the plates are heard from: the two verbs that happen out in the
99 // lane pass a `pan`, and the device builds one of these for each of them.
100 // The three that are the range's own voice pass none, and build none.
101 createStereoPanner() { return node("panner"); }
102 // And a compressor: the engine's device puts one on the end of its bus, so
103 // this range is heard through one too.
104 createDynamicsCompressor() { return node("limiter"); }
105 }
106 // A browser without the panner has no method at all, rather than one that
107 // answers null: that is the shape `src/engine/sound.ts` tests for.
108 if (options.panner === false)
109 delete (Fake.prototype as { createStereoPanner?: () => unknown }).createStereoPanner;
110 return {
111 Ctor: Fake as unknown as Ctor,
112 log,
113 nodes,
114 edges,
115 get contexts() { return state.contexts; },
116 };
117 }
118
119 /** The range's voice over a device over a fake context, and everything it wrote. */
12038 function audio(options: { panner?: boolean } = {}): {
12139 log: Event[]; nodes: Node[]; edges: Edge[]; readonly contexts: number; voice: RangeAudio;
12240 } {
123 const fake = fakeAudio(options);
41 const fake = fakeAudioContext({ ...options, currentTime: 0, state: "running" });
12442 return {
125 log: fake.log,
43 get log() { return fake.scheduled; },
12644 nodes: fake.nodes,
127 edges: fake.edges,
45 get edges() { return fake.edges; },
12846 get contexts() { return fake.contexts; },
12947 voice: createRangeAudio(createSoundDevice(fake.Ctor)),
13048 };
src/range/stage.test-helper.ts +41 −95
@@ -3,9 +3,9 @@
33 * `fakePage` behind the door hands back the document, the window, the elements,
44 * the 2D recorder and the clock, because both games needed exactly that and
55 * neither may import the other's copy of it. What is left here is what is
6 * genuinely this game's: the ids `src/pages/range.ts` renders, a counter for
7 * the `AudioContext` the engine's sound device opens, and the line that
8 * installs all of it as the globals `main.ts` reads for itself.
6 * genuinely this game's: the ids `src/pages/range.ts` renders, the voices it
7 * reads out of the engine's `fakeAudioContext`, and the line that installs all
8 * of it as the globals `main.ts` reads for itself.
99 *
1010 * The recorder the scene canvas answers `getContext("webgl2")` with is the
1111 * engine's `fakeGl` too: it is the renderer's double, so it sits behind the
@@ -16,7 +16,10 @@
1616 * It ships in no bundle: only `boot.test.ts` and `bundle.test.ts` import it.
1717 */
1818
19 import { fakeGl, fakePage, type FakeCanvas, type FakeDocument, type FakeElement, type FakeWindow } from "../engine/index.ts";
19 import {
20 fakeAudioContext, fakeGl, fakePage, type AudioEvent, type FakeCanvas, type FakeDocument, type FakeElement,
21 type FakeWindow,
22 } from "../engine/index.ts";
2023
2124 /** Every id `src/pages/range.ts` puts on the page that the entry looks up. */
2225 export const RANGE_IDS = ["stage", "scene", "hud", "note", "touch", "look", "fps", "full", "name"];
@@ -48,105 +51,48 @@ export type Voice = {
4851 export type FakeAudio = {
4952 /** Constructors run. Zero is a page that has not made a sound yet. */
5053 readonly contexts: number;
51 /** One entry per voice started, in the order they were asked for. */
52 voices: Voice[];
54 /** One entry per voice started, in the order they were asked for: `voicesOf` the log. */
55 readonly voices: Voice[];
5356 /** What `globalThis.AudioContext` is while this page is installed. */
5457 Ctor: new () => unknown;
5558 };
5659
57 export function audioCounter(): FakeAudio {
58 // `placed` is the pan of the panner built since the last voice started, or
59 // null when none was: the device builds a voice's panner and sets it before
60 // it starts the voice, and builds none at all for a sound with no pan, so
61 // reading it at `start` and clearing it there is how a row gets its side.
62 const state = { contexts: 0, gain: 0, placed: null as number | null };
60 /**
61 * The voices in a recording, folded out of the engine's log in order. The
62 * device sets a voice's envelope and places it before it starts it, so a
63 * `start` takes the last peak gain scheduled and the last side a panner was
64 * given since the previous start — and clears the side, because a voice with
65 * no pan builds no panner and is centre. The device assigns `pan.value` rather
66 * than scheduling it, which is why the recorder writes an assignment down as
67 * `panner.pan.value`: that is the event a side is read from. A tone's note is
68 * the last frequency its own oscillator was given, assigned or set; a burst's
69 * source was given none, so it reads 0.
70 */
71 export function voicesOf(log: readonly AudioEvent[]): Voice[] {
6372 const voices: Voice[] = [];
64 const param = (set: (value: number) => void) => {
65 const it = {
66 value: 0,
67 setValueAtTime: (value: number) => {
68 it.value = value;
69 set(value);
70 },
71 exponentialRampToValueAtTime: () => {},
72 };
73 return it;
74 };
75 // The device sets a voice's gain before it starts it, so the last gain asked
76 // for is this voice's: that is how a burst and its envelope land in one row.
77 const node = (kind: string) => {
78 const made = {
79 type: "",
80 buffer: null as unknown,
81 pan: param(() => {}),
82 frequency: param(() => {}),
83 playbackRate: param(() => {}),
84 Q: param(() => {}),
85 // The limiter's five knobs, which the engine's bus sets on the node it
86 // gets from `createDynamicsCompressor` below. A double without them is a
87 // page that throws on its first sound and a test that never says why.
88 threshold: param(() => {}),
89 knee: param(() => {}),
90 ratio: param(() => {}),
91 attack: param(() => {}),
92 release: param(() => {}),
93 gain: param((value) => void (state.gain = value)),
94 connect: (next: unknown) => next,
95 start: () => {
96 voices.push({
97 kind: kind as Voice["kind"],
98 frequency: made.frequency.value,
99 gain: state.gain,
100 pan: state.placed,
101 });
102 state.placed = null;
103 },
104 stop: () => {},
105 };
106 return made;
107 };
108 class Fake {
109 sampleRate = 48000;
110 currentTime = 0;
111 state = "running";
112 destination = { kind: "destination" };
113 constructor() {
114 state.contexts += 1;
73 const notes = new Map<string, number>();
74 let gain = 0;
75 let placed: number | null = null;
76 for (const event of log) {
77 if (/\.frequency\.(value|set)$/.test(event.kind)) notes.set(event.node, event.value);
78 else if (/\.gain\.set$/.test(event.kind)) gain = event.value;
79 else if (/^panner\.pan\.(value|set)$/.test(event.kind)) placed = event.value;
80 else if (event.kind === "source.start" || event.kind === "osc.start") {
81 const kind = event.kind === "osc.start" ? "osc" : "source";
82 voices.push({ kind, frequency: notes.get(event.node) ?? 0, gain, pan: placed });
83 placed = null;
11584 }
116 resume(): void {}
117 createBuffer(_channels: number, length: number) {
118 return { length, getChannelData: () => new Float32Array(length) };
119 }
120 createBufferSource() { return node("source"); }
121 createBiquadFilter() { return node("filter"); }
122 createGain() { return node("gain"); }
123 createOscillator() { return node("osc"); }
124 // The two sounds that happen out in the lane — a plate standing up and one
125 // going down — carry a pan, and the device builds one of these for each of
126 // them. The value it is set to is the side the next voice started is on.
127 createStereoPanner() {
128 const made = node("panner");
129 // The device assigns `pan.value` rather than scheduling it, so the side
130 // is caught with a setter: it lands in `placed`, and the next voice to
131 // start is the one it belongs to.
132 made.pan = {
133 get value() { return state.placed ?? 0; },
134 set value(side: number) { state.placed = side; },
135 setValueAtTime: (side: number) => void (state.placed = side),
136 exponentialRampToValueAtTime: () => {},
137 };
138 return made;
139 }
140 // And a compressor, for the same reason: every voice the engine's device
141 // starts goes to one master bus and out through a limiter on it, so a page
142 // whose context could not build one would be a page this counter never
143 // counted the same way a browser does.
144 createDynamicsCompressor() { return node("limiter"); }
14585 }
86 return voices;
87 }
88
89 /** The engine's fake context, running on a clock at zero, and the page's view of it. */
90 export function audioCounter(): FakeAudio {
91 const fake = fakeAudioContext({ currentTime: 0, state: "running" });
14692 return {
147 get contexts() { return state.contexts; },
148 voices,
149 Ctor: Fake as unknown as new () => unknown,
93 get contexts() { return fake.contexts; },
94 get voices() { return voicesOf(fake.log); },
95 Ctor: fake.Ctor,
15096 };
15197 }
15298

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs