Files
hyperframes/packages/core/src/runtime/audioFx.test.ts
T
Miguel Ángel afafca4b96 feat: make creator media edits render-safe (#3322)
* feat: make creator media edits render-safe

* fix: align media playback timing

* docs: add creator editing recipes

* docs: expand creator editing guidance

* fix: unify media source offsets

* fix: scale natural media duration

* fix: preserve natural media zero spans

* fix: align compiled natural media timing

* test: classify compiler media test as integration

* fix: drop inactive media windows

* fix: unify literal timing parsing

* fix: keep browser media parsing serializable

* fix: keep page timing readers strict

* fix: close remaining preview timing gaps

* fix(core): preserve Studio voice pitch at playback speed

* chore: keep creator contract source-neutral
2026-08-18 10:17:02 -04:00

888 lines
31 KiB
TypeScript

// @vitest-environment happy-dom
import { describe, expect, it } from "vitest";
import { attachElementFxChain } from "./audioFx.js";
import { defaultAudioFxParams, HF_AUDIO_FX, HF_AUDIO_FX_ATTR } from "../audioFx.js";
/**
* The DSP is proven in a real browser by the engine's render tests. What needs
* covering here is the splice: whether the chain gets inserted between the
* transport's source and its gain, and whether it stays out of the way when
* there is nothing to apply.
*/
class Node {
connections: Node[] = [];
disconnected = false;
frequency = { value: 0 };
Q = { value: 0 };
gain = { value: 0 };
delayTime = { value: 0 };
playbackRate = { value: 0 };
loop = false;
/** `start(when, offset)` — the offset is an LFO's phase, so it is asserted. */
startArgs: (number | undefined)[] | null = null;
type = "";
curve: Float32Array | null = null;
oversample = "none";
buffer: unknown = null;
normalize = true;
connect(n: Node): Node {
this.connections.push(n);
return n;
}
disconnect(): void {
this.disconnected = true;
}
start(...args: (number | undefined)[]): void {
this.startArgs = args;
}
stop(): void {}
}
class Ctx {
sampleRate = 48000;
createGain() {
return new Node();
}
createBiquadFilter() {
return new Node();
}
createIIRFilter() {
return new Node();
}
createDelay() {
return new Node();
}
createOscillator() {
return new Node();
}
createBufferSource() {
return new Node();
}
createWaveShaper() {
return new Node();
}
createConvolver() {
return new Node();
}
createBuffer(_c: number, length: number) {
return { length, getChannelData: () => new Float32Array(length) };
}
}
const ctx = () => new Ctx() as unknown as BaseAudioContext;
const el = (chain?: unknown) => ({
getAttribute: (n: string) => (n === "data-fx-chain" && chain ? JSON.stringify(chain) : null),
});
const CHAIN = {
version: 1,
nodes: [{ type: "peaking", params: { frequency: 1000, gain: -6, q: 1 } }],
};
describe("attachElementFxChain", () => {
it("connects source straight to destination when there is no chain", () => {
const src = new Node();
const dst = new Node();
attachElementFxChain(ctx(), el(), src as never, dst as never);
expect(src.connections).toContain(dst);
});
it("routes through the chain instead of directly when one is present", () => {
const src = new Node();
const dst = new Node();
const handle = attachElementFxChain(ctx(), el(CHAIN), src as never, dst as never);
expect(handle).not.toBeNull();
// The whole point: the dry path must no longer exist.
expect(src.connections).not.toContain(dst);
expect(src.connections).toHaveLength(1);
});
it("tolerates an element that cannot carry attributes", () => {
// The transport's element is any media-like object in some call paths.
const src = new Node();
const dst = new Node();
expect(() => attachElementFxChain(ctx(), {}, src as never, dst as never)).not.toThrow();
expect(src.connections).toContain(dst);
});
it("plays dry rather than silent when the chain is unreadable", () => {
const src = new Node();
const dst = new Node();
const handle = attachElementFxChain(
ctx(),
{
getAttribute: (name: string) => (name === HF_AUDIO_FX_ATTR ? "{not json" : null),
},
src as never,
dst as never,
);
expect(handle).not.toBeNull();
expect(src.connections).toContain(dst);
});
it("plays dry rather than silent when the chain names an unknown effect", () => {
const src = new Node();
const dst = new Node();
const handle = attachElementFxChain(
ctx(),
el({ version: 1, nodes: [{ type: "not-an-effect" }] }),
src as never,
dst as never,
);
expect(handle).not.toBeNull();
expect(src.connections).toContain(dst);
});
/**
* A structural edit — an effect added, removed or bypassed — used to be
* ignored here, so it only took effect when the persisting write reloaded the
* composition. That reload restarted every playing track, which is what was
* heard as the audio chopping. The graph is now swapped in place instead, with
* the source node left alone.
*/
describe("editing the chain while it plays", () => {
const audioEl = (chain?: unknown): HTMLElement => {
const node = document.createElement("audio");
if (chain) node.setAttribute("data-fx-chain", JSON.stringify(chain));
document.body.append(node);
return node;
};
/** Let the MutationObserver's microtask run. */
const settle = () => new Promise((r) => setTimeout(r, 0));
it("routes through an effect added to a track that had none", async () => {
const src = new Node();
const dst = new Node();
const node = audioEl();
attachElementFxChain(ctx(), node, src as never, dst as never);
expect(src.connections).toContain(dst);
node.setAttribute("data-fx-chain", JSON.stringify(CHAIN));
await settle();
// Now feeding the chain, not the gain directly.
expect(src.connections.at(-1)).not.toBe(dst);
});
it("returns to dry when the last effect is removed", async () => {
const src = new Node();
const dst = new Node();
const node = audioEl(CHAIN);
attachElementFxChain(ctx(), node, src as never, dst as never);
const firstTarget = src.connections[0] as Node;
expect(firstTarget).not.toBe(dst);
node.removeAttribute("data-fx-chain");
await settle();
expect(src.connections.at(-1)).toBe(dst);
// The graph that was in the path is torn down, not left running.
expect(firstTarget.disconnected).toBe(true);
});
it("swaps the graph without replacing the source node", async () => {
const src = new Node();
const dst = new Node();
const node = audioEl(CHAIN);
attachElementFxChain(ctx(), node, src as never, dst as never);
const before = src.connections[0] as Node;
node.setAttribute(
"data-fx-chain",
JSON.stringify({
version: 1,
nodes: [
{ type: "peaking", params: { frequency: 1000, gain: -6, q: 1 } },
{ type: "lowpass", params: { frequency: 800, q: 0.7, poles: "2" } },
],
}),
);
await settle();
const after = src.connections.at(-1) as Node;
// A different graph, reached from the same source: the audio never restarts.
expect(after).not.toBe(before);
expect(before.disconnected).toBe(true);
});
const violations: string[] = [];
/**
* An AudioParam with the browser's own scheduling rule: writing `.value`
* while a value curve covers the current time throws, because setting the
* property is a `setValueAtTime` at that instant and a curve may not overlap
* another event. Chrome words it exactly as this does.
*/
class SchedParam {
curves: { time: number; duration: number }[] = [];
sets: number[] = [];
#value = 0;
constructor(private clock: { currentTime: number }) {}
get value(): number {
return this.#value;
}
set value(v: number) {
const t = this.clock.currentTime;
const covering = this.curves.find((c) => t >= c.time && t <= c.time + c.duration);
if (covering) {
// Recorded as well as thrown: the throw happens inside a
// MutationObserver callback, where nothing rethrows it into the test —
// which is exactly why this only ever showed up as an uncaught error
// in the preview console.
const message =
`Failed to set the 'value' property on 'AudioParam': setValueAtTime(${v}, ${t}) ` +
`overlaps setValueCurveAtTime(..., ${covering.time}, ${covering.duration})`;
violations.push(message);
throw new Error(message);
}
this.#value = v;
}
setValueAtTime(v: number, t: number): void {
this.sets.push(t);
this.#value = v;
}
linearRampToValueAtTime(v: number): void {
this.#value = v;
}
setValueCurveAtTime(_v: Float32Array, time: number, duration: number): void {
this.curves.push({ time, duration });
}
cancelScheduledValues(from: number): void {
this.curves = this.curves.filter((c) => c.time + c.duration < from);
}
cancelAndHoldAtTime(from: number): void {
this.cancelScheduledValues(from);
}
}
it("edits a chain whose parameters an envelope is mid-curve on", async () => {
// The level stage a carve adds is automated, so a chain edit lands while a
// curve is running. Writing the new value straight onto the param throws
// NotSupportedError, which surfaced in the console as the preview refusing
// every edit after the first.
const clock = { currentTime: 0 };
class SchedNode extends Node {
override frequency = new SchedParam(clock) as unknown as { value: number };
override Q = new SchedParam(clock) as unknown as { value: number };
override gain = new SchedParam(clock) as unknown as { value: number };
}
class SchedCtx extends Ctx {
get currentTime(): number {
return clock.currentTime;
}
override createGain(): Node {
return new SchedNode();
}
override createBiquadFilter(): Node {
return new SchedNode();
}
}
// A gain stage, because its dB-to-linear mapping is what makes the
// scheduler emit a value curve instead of a ramp — and a curve is what a
// `.value` write may not land inside.
const chainWith = (freq: number) => ({
version: 1,
nodes: [
{ type: "gain", id: "n1", params: { gain: 0 } },
{ type: "peaking", id: "n2", params: { frequency: freq, gain: -6, q: 1 } },
],
});
const node = audioEl(chainWith(1000));
// A bent segment, so the scheduler emits a curve rather than a ramp.
node.setAttribute(
"data-automation",
JSON.stringify({
version: 1,
lanes: [
{
target: "fx.n1.gain",
points: [
{ t: 0, v: 0 },
{ t: 8, v: -12 },
],
},
],
}),
);
attachElementFxChain(
new SchedCtx() as unknown as BaseAudioContext,
node,
new Node() as never,
new Node() as never,
{ scheduledAt: 0, elapsed: 0, rate: 1 },
);
// Mid-curve, which is when the edit has to survive.
clock.currentTime = 3;
node.setAttribute("data-fx-chain", JSON.stringify(chainWith(1200)));
await settle();
expect(violations).toEqual([]);
expect(node.getAttribute("data-fx-chain")).toContain("1200");
});
/**
* A rebuild hands the graph the clip position it happens at, so an LFO
* resumes at the phase the render would be at rather than restarting.
*
* Without it, every structural edit — and every seek, which rebuilds the same
* way — put a chorus back at the top of its sweep wherever the playhead was:
* preview disagreeing with the render, and with itself across an edit.
*/
it("hands a rebuilt graph the playhead it happens at", async () => {
const clock = { currentTime: 0 };
class ClockCtx extends Ctx {
made: Node[] = [];
get currentTime(): number {
return clock.currentTime;
}
override createBufferSource(): Node {
const node = new Node();
this.made.push(node);
return node;
}
}
const withChorus = (mix: number) => ({
version: 1,
nodes: [
{
type: "chorus",
id: "n1",
params: { ...defaultAudioFxParams("chorus"), speed: 2, mix },
},
],
});
const ctxClock = new ClockCtx();
const node = audioEl(withChorus(0.5));
attachElementFxChain(
ctxClock as unknown as BaseAudioContext,
node,
new Node() as never,
new Node() as never,
{ scheduledAt: 0, elapsed: 0, rate: 1 },
);
// Attached at the clip's start: phase zero, the same as a render.
expect(ctxClock.made[0]?.startArgs?.[1]).toBeCloseTo(0, 6);
// 3.6 s later, and structural — a bypass, so the shape changes and the
// graph is rebuilt rather than re-parameterised.
clock.currentTime = 3.6;
node.setAttribute(
"data-fx-chain",
JSON.stringify({
version: 1,
nodes: [
...withChorus(0.5).nodes,
{ type: "peaking", id: "n2", params: defaultAudioFxParams("peaking") },
],
}),
);
await settle();
// 3.6 s at 2 Hz is seven cycles and a fifth.
expect(ctxClock.made.at(-1)?.startArgs?.[1]).toBeCloseTo(0.2, 6);
});
it("keeps playing dry when an edit leaves the chain unreadable", async () => {
const src = new Node();
const dst = new Node();
const node = audioEl(CHAIN);
attachElementFxChain(ctx(), node, src as never, dst as never);
node.setAttribute("data-fx-chain", "{not json");
await settle();
expect(src.connections.at(-1)).toBe(dst);
});
});
/**
* Lanes are committed to absolute context times, so the schedule is only
* right for the rate it was booked at. Bumping `playbackRate` alone left a
* lowpass sweeping over its original 10 wall-clock seconds while the audio
* underneath ran through 20 clip-seconds of material — and the runtime's
* stopAll()+reschedule recovery never fired for an unbounded source.
*/
describe("a rate change mid-playback", () => {
/** Records what was booked and when, without the browser's overlap rules. */
class TimedParam {
curves: { time: number; duration: number }[] = [];
ramps: number[] = [];
value = 0;
setValueAtTime(v: number): void {
this.value = v;
}
linearRampToValueAtTime(v: number, t: number): void {
this.ramps.push(t);
this.value = v;
}
setValueCurveAtTime(_v: Float32Array, time: number, duration: number): void {
this.curves.push({ time, duration });
}
cancelScheduledValues(): void {}
cancelAndHoldAtTime(): void {}
/** The last span booked, however the scheduler chose to express it. */
last(): { time: number; duration: number } | undefined {
return this.curves.at(-1);
}
}
const sweep = {
version: 1,
nodes: [{ type: "lowpass", id: "n1", params: { frequency: 300, q: 0.707 } }],
};
const lane = JSON.stringify({
version: 1,
lanes: [
{
target: "fx.n1.frequency",
points: [
{ t: 0, v: 300 },
{ t: 8, v: 3000 },
],
},
],
});
const build = () => {
const clock = { currentTime: 0 };
const made: { frequency: TimedParam }[] = [];
class TimedNode extends Node {
override frequency = new TimedParam() as unknown as { value: number };
}
class TimedCtx extends Ctx {
get currentTime(): number {
return clock.currentTime;
}
override createBiquadFilter(): Node {
const n = new TimedNode();
made.push(n as unknown as { frequency: TimedParam });
return n;
}
}
const node = document.createElement("audio");
node.setAttribute("data-fx-chain", JSON.stringify(sweep));
node.setAttribute("data-automation", lane);
document.body.append(node);
const handle = attachElementFxChain(
new TimedCtx() as unknown as BaseAudioContext,
node,
new Node() as never,
new Node() as never,
{ scheduledAt: 0, elapsed: 0, rate: 1 },
);
return { clock, node, handle, param: () => made[0]?.frequency as unknown as TimedParam };
};
it("re-aims the envelope so the sweep still ends with the material", () => {
const { clock, handle, param } = build();
// Booked at 1x: the whole 8 s lane spans 8 s of context time.
expect(param().last()).toEqual({ time: 0, duration: 8 });
clock.currentTime = 2;
handle?.setRate(2);
// 6 clip-seconds are left, and at 2x they take 3 wall-clock seconds.
// Without this the sweep kept its original plan to t=8 while the audio
// ran out at t=5.
expect(param().last()).toEqual({ time: 2, duration: 3 });
});
it("measures later edits from the new rate, not the one it started at", async () => {
// `elapsed` advances at whatever rate the reference frame holds, so a
// frame left at 1x re-aims every subsequent edit at the wrong clip
// position for as long as the track plays.
const { clock, node, handle, param } = build();
clock.currentTime = 2;
handle?.setRate(2);
clock.currentTime = 4;
// 2 wall-clock seconds at 2x is 4 clip-seconds, so the playhead is at 6
// and 2 clip-seconds remain: 1 second of wall clock.
node.setAttribute("data-automation", lane);
await new Promise((r) => setTimeout(r, 0));
expect(param().last()).toEqual({ time: 4, duration: 1 });
});
it("ignores a rate that is not a rate", () => {
const { clock, handle, param } = build();
clock.currentTime = 2;
handle?.setRate(0);
handle?.setRate(Number.NaN);
handle?.setRate(1);
expect(param().last()).toEqual({ time: 0, duration: 8 });
});
});
it("tears the chain down on dispose", () => {
const src = new Node();
const dst = new Node();
const handle = attachElementFxChain(ctx(), el(CHAIN), src as never, dst as never);
handle!.dispose();
expect((src.connections[0] as Node).disconnected).toBe(true);
});
/**
* A limiter, a compressor, a gate and a bitcrush are AudioWorklet processors,
* and a worklet node cannot be constructed before its module has registered. So
* a chain containing one plays dry for a moment and swaps in when registration
* resolves — and that path used to end there: no automation scheduled, no
* observer watching the attribute. Adding a compressor to a carved bed
* therefore killed the carve's envelopes and froze every later edit until the
* composition reloaded.
*/
describe("a chain that needs worklets before it can be built", () => {
/**
* Constructs only once the module has registered on that context, which is
* what Chrome does — and the whole reason this path exists. A stub that always
* succeeds hides the bug: the build then works even when nothing registered.
*/
class WorkletNodeStub extends Node {
port = { postMessage: () => {} };
constructor(ctx: { workletsReady?: boolean }, name: string) {
super("worklet");
if (!ctx.workletsReady) {
throw new Error(`AudioWorkletNode: processor "${name}" is not registered`);
}
}
}
/** A param that records whether an envelope was ever scheduled onto it. */
class RecordingParam {
scheduled = false;
value = 0;
setValueAtTime(): void {
this.scheduled = true;
}
linearRampToValueAtTime(): void {
this.scheduled = true;
}
setValueCurveAtTime(): void {
this.scheduled = true;
}
cancelScheduledValues(): void {}
cancelAndHoldAtTime(): void {}
}
/**
* A context whose worklet module registers when asked, and which hands out
* filters whose gain reports being automated.
*/
class WorkletCtx extends Ctx {
currentTime = 0;
workletsReady = false;
audioWorklet = {
addModule: async () => {
this.workletsReady = true;
},
};
biquads: { gain: RecordingParam }[] = [];
override createBiquadFilter() {
const node = new Node("biquad") as unknown as Node & { gain: RecordingParam };
node.gain = new RecordingParam();
this.biquads.push(node);
return node as unknown as Node;
}
}
const carved = {
version: 1,
nodes: [
{
type: "peaking",
id: "n1",
fromCarve: true,
params: { frequency: 1000, gain: -6, q: 1.4 },
},
{ type: "compressor", id: "n2", params: {} },
],
};
const lane = JSON.stringify({
version: 1,
lanes: [
{
target: "fx.n1.gain",
points: [
{ t: 0, v: 0 },
{ t: 4, v: -6 },
],
},
],
});
const audioEl = (): HTMLElement => {
const node = document.createElement("audio");
node.setAttribute("data-fx-chain", JSON.stringify(carved));
node.setAttribute("data-automation", lane);
document.body.append(node);
return node;
};
const settle = async () => {
await Promise.resolve();
await Promise.resolve();
await new Promise((r) => setTimeout(r, 0));
};
it("schedules the carve's envelopes once the processors land", async () => {
const original = (globalThis as { AudioWorkletNode?: unknown }).AudioWorkletNode;
(globalThis as { AudioWorkletNode?: unknown }).AudioWorkletNode = WorkletNodeStub;
try {
const worklets = new WorkletCtx();
const src = new Node();
const dst = new Node();
const node = audioEl();
attachElementFxChain(
worklets as unknown as BaseAudioContext,
node,
src as never,
dst as never,
{
scheduledAt: 0,
elapsed: 0,
rate: 1,
},
);
// Dry to begin with, which is the whole reason for this path.
expect(src.connections).toContain(dst);
await settle();
// Now routed through the chain — and the envelope is playing, which is
// what "the carve still works" means.
expect(src.connections.at(-1)).not.toBe(dst);
const filter = worklets.biquads[0];
expect(filter, "the carve's filter was never built").toBeTruthy();
expect(filter!.gain.scheduled).toBe(true);
} finally {
(globalThis as { AudioWorkletNode?: unknown }).AudioWorkletNode = original;
}
});
it("keeps the carve alive when a compressor is added to a playing track", async () => {
// The reported case, and the worse of the two paths: adding a worklet effect
// to an already-attached chain rebuilt it, the build threw because nothing
// on that path had registered the module, and the catch wired the source
// straight to its gain — dropping the carve's filters along with the
// compressor. Both symptoms, one cause.
const original = (globalThis as { AudioWorkletNode?: unknown }).AudioWorkletNode;
(globalThis as { AudioWorkletNode?: unknown }).AudioWorkletNode = WorkletNodeStub;
try {
const worklets = new WorkletCtx();
const src = new Node();
const dst = new Node();
// Starts with the carve only: no worklets, so it attaches immediately.
const node = document.createElement("audio");
node.setAttribute(
"data-fx-chain",
JSON.stringify({
version: 1,
nodes: [
{
type: "peaking",
id: "n1",
fromCarve: true,
params: { frequency: 1000, gain: -6, q: 1.4 },
},
],
}),
);
node.setAttribute("data-automation", lane);
document.body.append(node);
attachElementFxChain(
worklets as unknown as BaseAudioContext,
node,
src as never,
dst as never,
{ scheduledAt: 0, elapsed: 0, rate: 1 },
);
expect(worklets.biquads[0]!.gain.scheduled).toBe(true);
// Now add the compressor, as the panel's "Add effect" does.
node.setAttribute("data-fx-chain", JSON.stringify(carved));
await settle();
// Still routed through a chain rather than dry, the carve's filter rebuilt,
// and its envelope scheduled onto the new one.
expect(src.connections.at(-1)).not.toBe(dst);
const rebuilt = worklets.biquads.at(-1)!;
expect(rebuilt.gain.scheduled).toBe(true);
} finally {
(globalThis as { AudioWorkletNode?: unknown }).AudioWorkletNode = original;
}
});
it("follows the attribute after the late build, as any other chain does", async () => {
const original = (globalThis as { AudioWorkletNode?: unknown }).AudioWorkletNode;
(globalThis as { AudioWorkletNode?: unknown }).AudioWorkletNode = WorkletNodeStub;
try {
const worklets = new WorkletCtx() as unknown as BaseAudioContext;
const src = new Node();
const dst = new Node();
const node = audioEl();
attachElementFxChain(worklets, node, src as never, dst as never, {
scheduledAt: 0,
elapsed: 0,
rate: 1,
});
await settle();
const before = src.connections.at(-1);
node.setAttribute(
"data-fx-chain",
JSON.stringify({
version: 1,
nodes: [{ type: "lowpass", id: "n9", params: { frequency: 900, q: 1, poles: "2" } }],
}),
);
await settle();
expect(src.connections.at(-1)).not.toBe(before);
} finally {
(globalThis as { AudioWorkletNode?: unknown }).AudioWorkletNode = original;
}
});
});
/**
* Every effect in the registry, one at a time.
*
* The limiter/compressor bug was not really about worklets: it was a build that
* threw inside `attach`, whose catch wires the source straight to its gain —
* dropping every other effect in the chain, the carve's filters included. Any
* effect whose construction can fail has the same shape, so this asserts the two
* things that were broken for all fourteen: the chain reaches the signal path,
* and an envelope on it gets scheduled.
*/
describe("every registry effect, in the signal path", () => {
class RecordingParam {
scheduled = false;
value = 0;
setValueAtTime(): void {
this.scheduled = true;
}
linearRampToValueAtTime(): void {
this.scheduled = true;
}
setValueCurveAtTime(): void {
this.scheduled = true;
}
cancelScheduledValues(): void {}
cancelAndHoldAtTime(): void {}
}
class RecordingNode extends Node {
override frequency = new RecordingParam() as unknown as { value: number };
override Q = new RecordingParam() as unknown as { value: number };
override gain = new RecordingParam() as unknown as { value: number };
override delayTime = new RecordingParam() as unknown as { value: number };
// A modulated effect's `speed` lane drives its LFO's rate, which is where a
// looping buffer keeps the frequency an oscillator kept on `frequency`.
override playbackRate = new RecordingParam() as unknown as { value: number };
port = { postMessage: () => {} };
}
class RichCtx extends Ctx {
currentTime = 0;
workletsReady = false;
made: RecordingNode[] = [];
audioWorklet = {
addModule: async () => {
this.workletsReady = true;
},
};
private make(): Node {
const node = new RecordingNode("node");
this.made.push(node);
return node as unknown as Node;
}
override createGain() {
return this.make();
}
override createBiquadFilter() {
return this.make();
}
override createIIRFilter() {
return this.make();
}
override createDelay() {
return this.make();
}
override createOscillator() {
return this.make();
}
override createBufferSource() {
return this.make();
}
override createWaveShaper() {
return this.make();
}
override createConvolver() {
return this.make();
}
}
class WorkletNode extends RecordingNode {
constructor(ctx: { workletsReady?: boolean }, name: string) {
super("worklet");
if (!ctx.workletsReady) {
throw new Error(`AudioWorkletNode: processor "${name}" is not registered`);
}
}
}
const settle = async () => {
await Promise.resolve();
await Promise.resolve();
await new Promise((r) => setTimeout(r, 0));
};
for (const def of HF_AUDIO_FX) {
const automatable = def.params.find((p) => p.kind === "number" && p.automatable);
it(`routes ${def.id} and plays its envelope`, async () => {
const original = (globalThis as { AudioWorkletNode?: unknown }).AudioWorkletNode;
(globalThis as { AudioWorkletNode?: unknown }).AudioWorkletNode = WorkletNode;
try {
const ctxRich = new RichCtx();
const src = new Node();
const dst = new Node();
const node = document.createElement("audio");
node.setAttribute(
"data-fx-chain",
JSON.stringify({
version: 1,
nodes: [{ type: def.id, id: "n1", params: defaultAudioFxParams(def.id) }],
}),
);
if (automatable && automatable.kind === "number") {
node.setAttribute(
"data-automation",
JSON.stringify({
version: 1,
lanes: [
{
target: `fx.n1.${automatable.key}`,
points: [
{ t: 0, v: automatable.default },
{ t: 4, v: automatable.max },
],
},
],
}),
);
}
document.body.append(node);
attachElementFxChain(
ctxRich as unknown as BaseAudioContext,
node,
src as never,
dst as never,
{ scheduledAt: 0, elapsed: 0, rate: 1 },
);
await settle();
// Not dry: the effect is between the source and its gain.
expect(src.connections.at(-1), `${def.id} was left out of the path`).not.toBe(dst);
if (automatable) {
const scheduled = ctxRich.made.some((n) =>
[n.frequency, n.Q, n.gain, n.delayTime, n.playbackRate].some(
(p) => (p as unknown as RecordingParam).scheduled,
),
);
// A worklet exposes no AudioParams, so a lane on one has nowhere to
// write — that is by design and the scheduler skips it.
if (!def.web.startsWith("worklet-")) {
expect(scheduled, `${def.id}'s envelope was never scheduled`).toBe(true);
}
}
} finally {
(globalThis as { AudioWorkletNode?: unknown }).AudioWorkletNode = original;
}
});
}
});
});