mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-08 19:06:04 +00:00
* 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
888 lines
31 KiB
TypeScript
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;
|
|
}
|
|
});
|
|
}
|
|
});
|
|
});
|