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
Check · Review · Land · Live done — Landed and live in production.
torque/cs-engine:torque/332-one-audiocontext-fake-behind-the-door-th → main 1 commit fork
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; | ||
| 150 | 150 | |
| 151 | 151 | ## What is behind the door |
| 152 | 152 | |
| 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. | |
| 154 | 154 | |
| 155 | 155 | Every section below opens with what that group costs a bundle, because "what |
| 156 | 156 | 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 | ||
| 387 | 387 | throwing, so a fake that never grew the method plays the sound centred rather |
| 388 | 388 | than dropping the frame. |
| 389 | 389 | |
| 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 | ||
| 390 | 397 | ### loop |
| 391 | 398 | |
| 392 | 399 | **Weight**: 0.6 kB raw, 0.4 kB gzipped — this module alone. |
| @@ -884,7 +891,8 @@ you draw through `overlay` draws onto one. | ||
| 884 | 891 | What it deliberately does not know is what a renderer or a sound device is. |
| 885 | 892 | `FakeCanvas.gl` is an empty slot that `getContext("webgl2")` hands back, and what |
| 886 | 893 | 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`, | |
| 888 | 896 | and installing any of this as a global — a page that knew about those would be |
| 889 | 897 | one game's page wearing the engine's name. Your game's half lives beside your |
| 890 | 898 | 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 | ||
| 920 | 928 | `gl.test.ts` reads every built bundle to say so. Every game here boots on it — |
| 921 | 929 | the shooter, the range and the example each put one in their scene canvas. |
| 922 | 930 | |
| 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 | ||
| 923 | 967 | ### digest |
| 924 | 968 | |
| 925 | 969 | **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. | ||
| 1067 | 1111 | | the game supplies | the shooter | the range | |
| 1068 | 1112 | | --- | --- | --- | |
| 1069 | 1113 | | 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` | | |
| 1071 | 1115 | | a bindings table | `src/client/binding.ts` — buy, scoreboard, weapon slots | `src/range/binding.ts` — a trigger and a start, nothing else | |
| 1072 | 1116 | | a tick rate | 64 Hz | 50 Hz | |
| 1073 | 1117 | | 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 { | ||
| 153 | 153 | export { fakeGl } from "./gl.ts"; |
| 154 | 154 | export type { GlRecorder } from "./gl.ts"; |
| 155 | 155 | |
| 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 | ||
| 156 | 168 | /** |
| 157 | 169 | * A run folded into a hash: the quantiser, the fold with its checkpoints, and |
| 158 | 170 | * 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 @@ | ||
| 1 | 1 | import { expect, test } from "bun:test"; |
| 2 | 2 | import { |
| 3 | 3 | 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, | |
| 5 | 5 | } from "./sound.ts"; |
| 6 | import { | |
| 7 | fakeAudioContext, type AudioContextOptions, type AudioEvent, type AudioRecorder, type FakeAudioNode, | |
| 8 | type FakeParam, | |
| 9 | } from "./context.ts"; | |
| 6 | 10 | |
| 7 | 11 | // --- the arithmetic ------------------------------------------------------- |
| 8 | 12 | |
| @@ -78,121 +82,27 @@ test("panOf never leaves -1..1, and nonsense in is centre out", () => { | ||
| 78 | 82 | |
| 79 | 83 | // --- the wiring, against a context that only writes down what it was asked -- |
| 80 | 84 | |
| 81 | type Event = { kind: string; at: number; value: number; time: number }; | |
| 82 | ||
| 83 | 85 | /** One `connect` the device made, as the node it left and the node it reached. */ |
| 84 | 86 | type Edge = [from: string, to: string]; |
| 85 | 87 | |
| 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 | ||
| 89 | 88 | /** |
| 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. | |
| 91 | 91 | * `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. | |
| 99 | 95 | */ |
| 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[] }; | |
| 193 | 101 | } |
| 194 | 102 | |
| 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); | |
| 196 | 106 | |
| 197 | 107 | /** |
| 198 | 108 | * 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 @@ | ||
| 1 | 1 | { |
| 2 | "version": "0.9.0", | |
| 2 | "version": "0.10.0", | |
| 3 | 3 | "names": [ |
| 4 | 4 | "Activations", |
| 5 | 5 | "Adam", |
| 6 | "AudioContextOptions", | |
| 7 | "AudioEvent", | |
| 8 | "AudioRecorder", | |
| 6 | 9 | "Body", |
| 7 | 10 | "Box", |
| 8 | 11 | "Builder", |
| @@ -20,12 +23,14 @@ | ||
| 20 | 23 | "Effects", |
| 21 | 24 | "Escape", |
| 22 | 25 | "EscapeHost", |
| 26 | "FakeAudioNode", | |
| 23 | 27 | "FakeCanvas", |
| 24 | 28 | "FakeDocument", |
| 25 | 29 | "FakeElement", |
| 26 | 30 | "FakeKeyboard", |
| 27 | 31 | "FakePage", |
| 28 | 32 | "FakePaint", |
| 33 | "FakeParam", | |
| 29 | 34 | "FakeTarget", |
| 30 | 35 | "FakeWindow", |
| 31 | 36 | "Fold", |
| @@ -128,6 +133,7 @@ | ||
| 128 | 133 | "effectAt", |
| 129 | 134 | "expireEffects", |
| 130 | 135 | "eyeHeight", |
| 136 | "fakeAudioContext", | |
| 131 | 137 | "fakeGl", |
| 132 | 138 | "fakePage", |
| 133 | 139 | "fbm", |
src/range/audio.test.ts +18 −100
| @@ -1,9 +1,7 @@ | ||
| 1 | 1 | /** |
| 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. | |
| 7 | 5 | * |
| 8 | 6 | * Nothing here plays anything. What is checked is the recipe: how many voices |
| 9 | 7 | * a verb asks for, at what gain, for how long, that distance is in the gain at |
| @@ -11,120 +9,40 @@ | ||
| 11 | 9 | */ |
| 12 | 10 | |
| 13 | 11 | 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"; | |
| 15 | 15 | import { |
| 16 | 16 | CRACK, HIT, LANE_PAN, OVER, POP, TICK, createRangeAudio, loudness, sideOf, type RangeAudio, |
| 17 | 17 | } from "./audio.ts"; |
| 18 | 18 | |
| 19 | type Event = { kind: string; value: number; time: number }; | |
| 19 | type Event = AudioEvent; | |
| 20 | 20 | |
| 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; | |
| 27 | 22 | |
| 28 | 23 | /** One `connect` the device made: the node it left, and the node it reached. */ |
| 29 | 24 | type Edge = [from: string, to: string]; |
| 30 | 25 | |
| 31 | 26 | /** |
| 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`. | |
| 33 | 33 | * |
| 34 | 34 | * `panner: false` is a context with no `createStereoPanner` on it at all: the |
| 35 | 35 | * 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. | |
| 38 | 37 | */ |
| 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. */ | |
| 120 | 38 | function audio(options: { panner?: boolean } = {}): { |
| 121 | 39 | log: Event[]; nodes: Node[]; edges: Edge[]; readonly contexts: number; voice: RangeAudio; |
| 122 | 40 | } { |
| 123 | const fake = fakeAudio(options); | |
| 41 | const fake = fakeAudioContext({ ...options, currentTime: 0, state: "running" }); | |
| 124 | 42 | return { |
| 125 | log: fake.log, | |
| 43 | get log() { return fake.scheduled; }, | |
| 126 | 44 | nodes: fake.nodes, |
| 127 | edges: fake.edges, | |
| 45 | get edges() { return fake.edges; }, | |
| 128 | 46 | get contexts() { return fake.contexts; }, |
| 129 | 47 | voice: createRangeAudio(createSoundDevice(fake.Ctor)), |
| 130 | 48 | }; |
src/range/stage.test-helper.ts +41 −95
| @@ -3,9 +3,9 @@ | ||
| 3 | 3 | * `fakePage` behind the door hands back the document, the window, the elements, |
| 4 | 4 | * the 2D recorder and the clock, because both games needed exactly that and |
| 5 | 5 | * 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. | |
| 9 | 9 | * |
| 10 | 10 | * The recorder the scene canvas answers `getContext("webgl2")` with is the |
| 11 | 11 | * engine's `fakeGl` too: it is the renderer's double, so it sits behind the |
| @@ -16,7 +16,10 @@ | ||
| 16 | 16 | * It ships in no bundle: only `boot.test.ts` and `bundle.test.ts` import it. |
| 17 | 17 | */ |
| 18 | 18 | |
| 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"; | |
| 20 | 23 | |
| 21 | 24 | /** Every id `src/pages/range.ts` puts on the page that the entry looks up. */ |
| 22 | 25 | export const RANGE_IDS = ["stage", "scene", "hud", "note", "touch", "look", "fps", "full", "name"]; |
| @@ -48,105 +51,48 @@ export type Voice = { | ||
| 48 | 51 | export type FakeAudio = { |
| 49 | 52 | /** Constructors run. Zero is a page that has not made a sound yet. */ |
| 50 | 53 | 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[]; | |
| 53 | 56 | /** What `globalThis.AudioContext` is while this page is installed. */ |
| 54 | 57 | Ctor: new () => unknown; |
| 55 | 58 | }; |
| 56 | 59 | |
| 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[] { | |
| 63 | 72 | 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; | |
| 115 | 84 | } |
| 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"); } | |
| 145 | 85 | } |
| 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" }); | |
| 146 | 92 | 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, | |
| 150 | 96 | }; |
| 151 | 97 | } |
| 152 | 98 |
new above
offline