From 90008fceee53f2712e00238bc7462989fe7d38d3 Mon Sep 17 00:00:00 2001 From: ibrahimweng Date: Sun, 30 Aug 2026 11:24:59 +0000 Subject: [PATCH] Test the audio graph by rendering it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The README said the graph could not be tested, and gave the reason in audio-buffer.ts: Node has no Web Audio, so the encoders could be checked against a stub of an AudioBuffer while the thing that fills one could not. That was true of the stub and not of the graph. The export path is an OfflineAudioContext rendering faster than real time, which is a plain function of its inputs in every sense that matters — the same project gives the same samples, with no page, no device and no clock. It only ever needed an implementation. node-web-audio-api is one, in Rust, and it carries every node this app builds with. So fifteen tests that ask the questions that are actually about sound, measured off the rendered samples rather than reasoned about: - a cue starts on the sample its time works out to, and there is silence before it. This is what makes an exported file line up when it is dropped on a track at zero, so it is the deliverable rather than an internal detail - an end-anchored cue begins a length before its marker, which is the rule the whole anchor idea rests on - mute silences a cue and a layer; solo silences everything else - gain on a cue and on a layer are both applied - the same placed sound renders identically every time, and two placements of it differ — the seed comes from the cue's own id, and a seed that were not actually read would pass the first and fail the second - the stems add back up to the mix, sample for sample, which is the claim renderStems makes and the one that fails first if anything in the chain is drawn fresh per render - the file runs past the video so a tail is not cut off, stops at it when asked, and always starts at zero Every one was checked by putting its fault back into the code and watching it fail: the anchor ignored, solo ignored, the seed made random, every cue nudged eighty milliseconds late, and trimToDuration ignored. Six tests failed across those five, each the one that names the fault. Writing them also cost a minute a run until I noticed the sum was calling getChannelData inside its own per-sample loop. It hands back a copy here rather than a view, so that was a copy of the whole channel per sample. Hoisted, the file runs in six seconds. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Ux1kydvUkLRoMbHp82ofDi --- README.md | 19 ++- package-lock.json | 130 +++++++++++++++ package.json | 1 + src/audio/render.test.ts | 341 +++++++++++++++++++++++++++++++++++++++ test/web-audio.ts | 47 ++++++ vitest.config.ts | 9 ++ 6 files changed, 546 insertions(+), 1 deletion(-) create mode 100644 src/audio/render.test.ts create mode 100644 test/web-audio.ts diff --git a/README.md b/README.md index 43c589d..e208d05 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,20 @@ them they read four things off it: how many channels there are, how long it is, what rate it was made at, and the samples. `test/audio-buffer.ts` is those four things and nothing else. +The audio graph is here too, which for a while it was not. The export path is +an `OfflineAudioContext` rendering faster than real time, and that is a plain +function of its inputs in every sense that matters: the same project gives the +same samples, with no page, no device and no clock. It only ever needed an +implementation, and `node-web-audio-api` is one — `test/web-audio.ts` puts its +names on the global before the tests run. So `render.test.ts` asks the +questions that are actually about sound: does a cue start on the sample its +time works out to, does an end-anchored one begin a length earlier, does mute +silence it, does solo silence everything else, do the stems add back up to the +mix, and is the same placed sound the same file every time it is rendered. +What that does not check is that two Web Audio implementations sound alike — +it checks that we schedule and mix what we meant to. The browser suite is where +the real thing gets driven. + `test/fixtures/motion-shapes.json` holds real measurements taken by the scan itself, off a clip built with one of each shape in it: a cut, a build, a move, a flurry and a still passage. It is kept because it is decoder output rather @@ -126,7 +140,10 @@ video's and the stage carries fourteen pixels of padding; and zooming back out never quite reached Fit, since 2.56 divided by 1.6 twice is 1.0000000000000002 rather than 1. -What is not tested yet: the parts that reach the audio graph. +What is not tested yet: the live playback path — the clock, the buses and the +scheduling that happen while you are listening rather than exporting. The +browser suite covers the playhead crossing the end of the clip; what a sound +does under a moving playhead it does not. ## Deploying diff --git a/package-lock.json b/package-lock.json index 7f2176a..2251391 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "devDependencies": { "@playwright/test": "^1.62.1", "@types/node": "^26.4.0", + "node-web-audio-api": "^2.2.0", "typescript": "^5.9.3", "vite": "^7.3.6", "vitest": "^4.1.11" @@ -1035,6 +1036,13 @@ "node": ">=12" } }, + "node_modules/caller": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/caller/-/caller-1.1.0.tgz", + "integrity": "sha512-n+21IZC3j06YpCWaxmUy5AnVqhmCIM2bQtqQyy00HJlmStRt6kwDX5F9Z97pqwAB+G/tgSz6q/kUBbNyQzIubw==", + "dev": true, + "license": "MIT" + }, "node_modules/chai": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", @@ -1052,6 +1060,16 @@ "dev": true, "license": "MIT" }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/es-module-lexer": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", @@ -1139,6 +1157,43 @@ } } }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1183,6 +1238,61 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/node-web-audio-api": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/node-web-audio-api/-/node-web-audio-api-2.2.0.tgz", + "integrity": "sha512-qM3dW3Z5HIz+O+++Uiia8x0OSNHcfJGUlexpCTdAkw8JlM17Ru1nV9a94wlxNRXqxT0Lr/ewwEnBXUwGMt24AA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "caller": "^1.1.0", + "node-fetch": "^3.3.2", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": ">= 22" + } + }, "node_modules/obug": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", @@ -1616,6 +1726,26 @@ } } }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", diff --git a/package.json b/package.json index be2a490..864cc2b 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "devDependencies": { "@playwright/test": "^1.62.1", "@types/node": "^26.4.0", + "node-web-audio-api": "^2.2.0", "typescript": "^5.9.3", "vite": "^7.3.6", "vitest": "^4.1.11" diff --git a/src/audio/render.test.ts b/src/audio/render.test.ts new file mode 100644 index 0000000..f01fbf8 --- /dev/null +++ b/src/audio/render.test.ts @@ -0,0 +1,341 @@ +import { describe, expect, it } from 'vitest'; +import { emptyProject } from '../timeline/project.ts'; +import type { Cue, Project } from '../timeline/types.ts'; +import { DEFAULT_CHAIN } from './chain.ts'; +import { renderProject, renderStems } from './render.ts'; + +/** + * The graph, rendered and measured. + * + * Everything else in this folder that is tested is arithmetic on the way to + * the graph -- which sound belongs on which moment, what a curve describes. + * This is the graph itself: a project goes in and samples come out, and the + * claims worth checking are about where a sound lands, how loud it is, and + * whether the things that are supposed to silence it do. + * + * They matter because this is the export path. What `renderProject` produces + * is what gets written to the file somebody drops on a track, so "the cue is + * at 1.2 seconds" is not an internal detail, it is the deliverable. Nothing + * here is performed or captured: it is an offline render, so the same project + * gives the same samples every time and a measurement is exact rather than + * approximate. + */ + +const RATE = 48_000; + +/* + * Rendering is not instant, even offline. + * + * Each of these builds the full chain -- a convolver with a two second + * impulse among it -- and renders several seconds through it, which is a few + * seconds of work rather than a few milliseconds. The default five is for + * tests that are arithmetic. + */ +const SLOW = 60_000; + +/** A design voice, which is synthesised, so no file has to be found first. */ +function cue(over: Partial & { id: string; time: number; layerId: string }): Cue { + return { + source: { kind: 'design', name: 'impact' }, + gain: 1, + tune: 0, + length: 0.6, + anchor: 'start', + space: 0, + drive: 0, + vary: 0, + muted: false, + ...over, + }; +} + +/** A project of a given length with these cues on its first layer. */ +function project(seconds: number, cues: Cue[], over: Partial = {}): Project { + const base = emptyProject(); + return { ...base, duration: seconds, cues, ...over }; +} + +/** The layer ids a fresh project starts with. */ +const LAYERS = emptyProject().layers.map((layer) => layer.id); + +/** Loudest sample anywhere in a stretch of the render, both channels. */ +function peak(buffer: AudioBuffer, from = 0, to = buffer.length): number { + let most = 0; + for (let channel = 0; channel < buffer.numberOfChannels; channel += 1) { + const samples = buffer.getChannelData(channel); + for (let at = Math.max(0, from); at < Math.min(samples.length, to); at += 1) { + const size = Math.abs(samples[at]); + if (size > most) most = size; + } + } + return most; +} + +/** + * The moment the render stops being silent, in seconds. + * + * A threshold rather than the first non-zero sample: a convolver fed silence + * still writes denormal-sized numbers, and the question being asked is when a + * sound starts rather than when the arithmetic does. + */ +function firstSound(buffer: AudioBuffer, floor = 0.005): number | null { + const left = buffer.getChannelData(0); + const right = buffer.numberOfChannels > 1 ? buffer.getChannelData(1) : left; + for (let at = 0; at < left.length; at += 1) { + if (Math.abs(left[at]) > floor || Math.abs(right[at]) > floor) return at / buffer.sampleRate; + } + return null; +} + +const render = (one: Project, over: Partial[1]> = {}) => + renderProject(one, { settings: DEFAULT_CHAIN, sampleRate: RATE, ...over }); + +describe('where a sound lands', () => { + /* + * The claim the export is built on, checked against the samples. + * + * "Every cue lands on the exact sample its time works out to" is what makes + * the exported file line up when it is dropped on a track at zero. A drift + * of a frame or two would be inaudible on its own and wrong against + * picture, which is the whole job. + */ + it('starts a cue at the time it is placed', async () => { + const { parts } = await render(project(4, [cue({ id: 'c1', time: 1.25, layerId: LAYERS[0] })])); + const at = firstSound(parts[0].buffer); + + expect(at, 'something sounded').not.toBeNull(); + // Within a millisecond: the voice's own attack is not instantaneous, so + // the first sample over the floor is a hair late by construction. + expect(at!).toBeGreaterThanOrEqual(1.25); + expect(at!).toBeLessThan(1.26); + }); + + it('leaves silence before it', async () => { + const { parts } = await render(project(4, [cue({ id: 'c1', time: 1.25, layerId: LAYERS[0] })])); + const before = Math.floor(1.2 * RATE); + expect(peak(parts[0].buffer, 0, before), 'nothing before the cue').toBeLessThan(0.005); + }); + + /* + * A cue anchored to its end finishes on its marker rather than starting on + * it, so it has to begin one length earlier. That is the rule the whole + * "anchor" idea rests on: a riser is placed against the hit it leads into. + */ + it('starts an end-anchored cue a length before its marker', async () => { + const { parts } = await render( + project(4, [cue({ id: 'c1', time: 2, layerId: LAYERS[0], anchor: 'end', length: 0.8 })]), + ); + const at = firstSound(parts[0].buffer); + + expect(at, 'something sounded').not.toBeNull(); + expect(at!, 'it begins a length before the marker').toBeGreaterThanOrEqual(1.2); + expect(at!).toBeLessThan(1.25); + }); +}); + +describe('what silences a sound', () => { + it('a muted cue makes no sound at all', async () => { + const { parts } = await render( + project(3, [cue({ id: 'c1', time: 0.5, layerId: LAYERS[0], muted: true })]), + ); + // Still a file, and still the right length: a mix is always rendered, and + // a silent one is the correct answer to a project with nothing audible on + // it. What is being checked is that nothing sounds, not that nothing ran. + expect(parts, 'a mix is still produced').toHaveLength(1); + expect(peak(parts[0].buffer), 'and it is silent').toBeLessThan(1e-4); + }); + + it('a muted layer silences what is on it', async () => { + const one = project(3, [cue({ id: 'c1', time: 0.5, layerId: LAYERS[0] })]); + const silenced: Project = { + ...one, + layers: one.layers.map((layer) => + layer.id === LAYERS[0] ? { ...layer, muted: true } : layer, + ), + }; + expect(peak((await render(silenced)).parts[0].buffer), 'silent while muted') + .toBeLessThan(1e-4); + expect(peak((await render(one)).parts[0].buffer), 'and audible when it is not') + .toBeGreaterThan(0.01); + }); + + /* + * Solo is the other half of mute and the half that is easy to get wrong: + * it silences everything that is not soloed, including layers nobody + * touched. + */ + it('solo on one layer silences the others', async () => { + const one = project(3, [ + cue({ id: 'c1', time: 0.4, layerId: LAYERS[0] }), + cue({ id: 'c2', time: 1.6, layerId: LAYERS[1] }), + ]); + const soloed: Project = { + ...one, + layers: one.layers.map((layer) => (layer.id === LAYERS[1] ? { ...layer, solo: true } : layer)), + }; + + const { parts } = await render(soloed); + const first = Math.floor(1.2 * RATE); + expect(peak(parts[0].buffer, 0, first), 'the layer that is not soloed is gone') + .toBeLessThan(0.005); + expect(peak(parts[0].buffer, first), 'the soloed one is still there').toBeGreaterThan(0.01); + }); +}); + +describe('how loud a sound is', () => { + it('a quieter cue renders quieter', async () => { + const loud = await render(project(3, [cue({ id: 'c1', time: 0.5, layerId: LAYERS[0] })])); + const soft = await render( + project(3, [cue({ id: 'c1', time: 0.5, layerId: LAYERS[0], gain: 0.25 })]), + ); + + const one = peak(loud.parts[0].buffer); + const other = peak(soft.parts[0].buffer); + expect(one, 'the loud one is audible').toBeGreaterThan(0.01); + expect(other, 'the quiet one is quieter').toBeLessThan(one * 0.6); + expect(other, 'but not silent').toBeGreaterThan(0); + }); + + it('a layer turned down turns down what is on it', async () => { + const one = project(3, [cue({ id: 'c1', time: 0.5, layerId: LAYERS[0] })]); + const down: Project = { + ...one, + layers: one.layers.map((layer) => (layer.id === LAYERS[0] ? { ...layer, gain: 0.2 } : layer)), + }; + + const full = peak((await render(one)).parts[0].buffer); + const quiet = peak((await render(down)).parts[0].buffer); + expect(quiet).toBeLessThan(full * 0.6); + expect(quiet).toBeGreaterThan(0); + }); +}); + +describe('the same sound twice', () => { + /* + * A placed sound is one sound, not a new one each time it is heard. + * + * The noise a voice is built from is drawn from the cue's own id, so + * auditioning it, playing the timeline and exporting all produce the same + * thing. Without that a set of stems adds up to the mix only nearly, and + * "nearly" in a sum of noise is audible. + */ + it('renders identically from the same project', async () => { + const one = project(3, [cue({ id: 'fixed', time: 0.5, layerId: LAYERS[0], vary: 0.8 })]); + const first = (await render(one)).parts[0].buffer.getChannelData(0); + const again = (await render(one)).parts[0].buffer.getChannelData(0); + + expect(again.length).toBe(first.length); + let worst = 0; + for (let at = 0; at < first.length; at += 1) { + worst = Math.max(worst, Math.abs(first[at] - again[at])); + } + expect(worst, 'sample for sample the same').toBe(0); + }); + + /* + * And the other half of it: two placements of the same sound are meant to + * differ, drawn from their own ids. A seed that is not actually read would + * pass the test above and fail this one. + */ + it('differs between two placements of the same sound', async () => { + const settings = { time: 0.5, layerId: LAYERS[0], vary: 1 }; + const one = (await render(project(3, [cue({ id: 'aaa', ...settings })]))).parts[0].buffer; + const other = (await render(project(3, [cue({ id: 'zzz', ...settings })]))).parts[0].buffer; + + const first = one.getChannelData(0); + const second = other.getChannelData(0); + let worst = 0; + for (let at = 0; at < first.length; at += 1) { + worst = Math.max(worst, Math.abs(first[at] - second[at])); + } + expect(worst, 'two takes of the same sound are not the same file').toBeGreaterThan(0.001); + }); +}); + +describe('stems', () => { + /* + * The claim in `renderStems`: the stems add back up to the mix. + * + * Somebody handed a set of stems balances them against each other and + * expects that starting position to be the mix they were shown. Every part + * of the chain that carries between files -- the reverb above all -- has to + * be identical in each, which is the part that would not be true if the + * noise were drawn fresh per render. + */ + it('add back up to the mix', { timeout: SLOW }, async () => { + const one = project(4, [ + cue({ id: 'c1', time: 0.4, layerId: LAYERS[0] }), + cue({ id: 'c2', time: 1.1, layerId: LAYERS[1], source: { kind: 'design', name: 'whoosh' } }), + cue({ id: 'c3', time: 2.2, layerId: LAYERS[0], source: { kind: 'design', name: 'sub' } }), + ]); + + const mix = (await render(one)).parts; + const stems = (await renderStems(one, { settings: DEFAULT_CHAIN, sampleRate: RATE })).parts; + + expect(mix, 'one mixed file').toHaveLength(1); + expect(stems.length, 'a file per layer that has something on it').toBeGreaterThan(1); + + const mixed = mix[0].buffer; + expect(peak(mixed), 'the mix is audible').toBeGreaterThan(0.01); + + for (let channel = 0; channel < mixed.numberOfChannels; channel += 1) { + const wanted = mixed.getChannelData(channel); + /* + * Read once per channel, not once per sample. + * + * `getChannelData` hands back a copy here rather than a view onto the + * buffer, so calling it inside the loop turned a sum over a few hundred + * thousand samples into a minute of copying. Cheap in a browser, not + * cheap everywhere. + */ + const each = stems.map((stem) => stem.buffer.getChannelData(channel)); + let worst = 0; + for (let at = 0; at < wanted.length; at += 1) { + let sum = 0; + for (const samples of each) sum += samples[at]; + worst = Math.max(worst, Math.abs(sum - wanted[at])); + } + // Not exact: the stems are summed in a different order from the graph's + // own mix, and float addition is not associative. A thousandth is far + // below anything audible and far above the error. + expect(worst, `channel ${channel} adds back up`).toBeLessThan(0.001); + } + }); + + it('are all the same length, so they stay lined up', async () => { + const one = project(4, [ + cue({ id: 'c1', time: 0.4, layerId: LAYERS[0] }), + cue({ id: 'c2', time: 3.5, layerId: LAYERS[1] }), + ]); + const { parts } = await renderStems(one, { settings: DEFAULT_CHAIN, sampleRate: RATE }); + + const lengths = new Set(parts.map((part) => part.buffer.length)); + expect(lengths.size, `lengths were ${[...lengths]}`).toBe(1); + }); +}); + +describe('how long the file is', () => { + /* + * A tail is allowed to finish by default, which is why the file is longer + * than the video. `trimToDuration` is for the case where it must not be. + */ + it('runs past the video so a tail is not cut off', async () => { + const one = project(2, [cue({ id: 'c1', time: 1.5, layerId: LAYERS[0], length: 1.5 })]); + const { parts } = await render(one); + expect(parts[0].buffer.length / RATE, 'there is room for the tail').toBeGreaterThan(2); + }); + + it('stops at the video when it is asked to', async () => { + const one = project(2, [cue({ id: 'c1', time: 1.5, layerId: LAYERS[0], length: 1.5 })]); + const { parts } = await render(one, { trimToDuration: true }); + expect(parts[0].buffer.length / RATE, 'exactly the video').toBeCloseTo(2, 3); + }); + + it('always starts at zero, whatever is on it', async () => { + const late = project(5, [cue({ id: 'c1', time: 4, layerId: LAYERS[0] })]); + const { parts } = await render(late); + // A file that started at the first sound would line up nowhere. + expect(firstSound(parts[0].buffer)!, 'the silence at the front is kept') + .toBeGreaterThan(3.9); + }); +}); diff --git a/test/web-audio.ts b/test/web-audio.ts new file mode 100644 index 0000000..d21ebab --- /dev/null +++ b/test/web-audio.ts @@ -0,0 +1,47 @@ +import { + AudioBuffer, + AudioContext, + BiquadFilterNode, + ConvolverNode, + GainNode, + OfflineAudioContext, + OscillatorNode, + PeriodicWave, +} from 'node-web-audio-api'; + +/** + * Web Audio in Node, so the graph can be rendered rather than reasoned about. + * + * The comment in `audio-buffer.ts` next door used to end "which is the reason + * these two files can be tested at all while the graph that fills them + * cannot". That was true of a stub and not true of the thing itself: the + * export path is an `OfflineAudioContext` rendering faster than real time, + * which is a plain function of its inputs in every sense that matters — the + * same project in gives the same samples out, with no page, no device and no + * clock. It only ever needed an implementation. + * + * `node-web-audio-api` is one, in Rust, and it carries every node this app + * builds with: convolver, biquad, compressor, shaper, panner and the rest. + * What it is not is Chromium, so this checks that we schedule and mix what we + * meant to, not that two implementations sound identical. The browser suite is + * where the real thing gets driven. + * + * Installed as globals rather than imported by the modules under test, since + * `audio/` is written against the browser's names and should stay that way: + * the day it imports from a test package is the day it stops being the code + * that ships. + */ +const globals = { + OfflineAudioContext, + AudioContext, + AudioBuffer, + OscillatorNode, + GainNode, + BiquadFilterNode, + ConvolverNode, + PeriodicWave, +}; + +for (const [name, value] of Object.entries(globals)) { + Object.defineProperty(globalThis, name, { value, writable: true, configurable: true }); +} diff --git a/vitest.config.ts b/vitest.config.ts index 9f1b7ea..5bf3806 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -18,6 +18,15 @@ export default defineConfig({ test: { environment: 'node', include: ['src/**/*.test.ts'], + /* + * Web Audio's names, put on the global before anything under test runs. + * + * Only the render tests need them, and they need them at import time + * rather than inside a test, so it goes here rather than in the file. See + * `test/web-audio.ts` for why rendering the graph in Node is a reasonable + * thing to want. + */ + setupFiles: ['./test/web-audio.ts'], // A test beside the thing it tests, which is how the rest of the tree is // arranged: one file per concern, in the folder that concern lives in. restoreMocks: true,