Skip to content

Commit 5f7e0da

Browse files
vanceingallsclaude
andcommitted
fix(core): make audio automation survive being rescheduled mid-playback
Anything landing inside a running value curve is refused unless the parameter is cancelled first, and two paths were not cancelling: the chain observer wrote each knob straight onto its AudioParam before rescheduling, and a bent segment read as straight because only the curve exponent was checked, never the via point the timeline actually writes. The first threw NotSupportedError into the console and abandoned the rest of the envelope; the second played a dragged bend as a ramp. Measured against Chrome, in a live context and in an offline one suspended mid-curve: any cancel frees the span, and only a missing cancel is refused. clearParamLane takes the strongest form on purpose, because curve-over-curve refusals were reported with a cancel at the new schedule time already in place and have never reproduced; emit keeps a ramp fallback as the backstop for whatever that mechanism turns out to be. Dynamic carve is what exercises all of it, so it lands here too: - a `gain` primitive, so a carve can match levels as well as carve bands - carve settings collapse to one `strength`, with carveProfile deriving the six numbers that always moved together anyway - analyseCarveDynamics / analyseCarveDuck turn the analysis into envelopes, with a slow release so the bed does not snap back the instant a word ends - worklets are awaited inside attach, so adding a compressor to a carved bed no longer kills its envelopes and freezes every later edit - per-track failure detail in the render's audio stage, which was being discarded Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 4d13314 commit 5f7e0da

13 files changed

Lines changed: 2012 additions & 90 deletions

packages/core/src/audio/audioFxAutomation.test.ts

Lines changed: 265 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import { describe, expect, it } from "vitest";
33
import {
44
cancelParamLane,
5+
clearParamLane,
56
scheduleChainAutomation,
67
scheduleParamLane,
78
volumeLane,
@@ -68,7 +69,11 @@ describe("scheduleParamLane", () => {
6869
const { target, param } = fake();
6970
scheduleParamLane([target], ramp, "linear", at(0));
7071
expect(param.calls).toEqual([
71-
{ op: "cancel", time: 10 },
72+
// Cleared from zero, not held: holding leaves the span of a running curve
73+
// booked, and Chrome refuses the next curve — or a plain `.value` write —
74+
// that lands inside it. The seed below restores the value in the same pass,
75+
// so clearing costs nothing audible.
76+
{ op: "cancel", time: 0 },
7277
// Before the first point the envelope holds that point's value.
7378
{ op: "set", value: 0.2, time: 10 },
7479
{ op: "ramp", value: 0.8, time: 13 },
@@ -87,7 +92,7 @@ describe("scheduleParamLane", () => {
8792
const { target, param } = fake();
8893
scheduleParamLane([target], ramp, "linear", at(9));
8994
expect(param.calls).toEqual([
90-
{ op: "cancel", time: 10 },
95+
{ op: "cancel", time: 0 },
9196
{ op: "set", value: 0.8, time: 10 },
9297
]);
9398
});
@@ -122,7 +127,7 @@ describe("scheduleParamLane", () => {
122127
at(0),
123128
);
124129
expect(param.calls).toEqual([
125-
{ op: "cancel", time: 10 },
130+
{ op: "cancel", time: 0 },
126131
{ op: "set", value: 0.4, time: 10 },
127132
]);
128133
});
@@ -152,6 +157,31 @@ describe("scheduleParamLane", () => {
152157
expect(curve.values[Math.floor(curve.values.length / 2)]).toBeLessThan(0.4);
153158
});
154159

160+
it("samples a segment bent by a via point, not just one bent by `curve`", () => {
161+
// Both express the same thing — a segment that is not a straight line — and
162+
// the via form is what the timeline writes when a bend is dragged. Read as
163+
// straight, the whole bend was played as a linear ramp: the envelope drawn in
164+
// the lane and the envelope heard were different shapes.
165+
const { target, param } = fake();
166+
scheduleParamLane(
167+
[target],
168+
{
169+
target: "volume",
170+
points: [
171+
{ t: 0, v: 0, viaX: 0.5, viaY: 0.9 },
172+
{ t: 2, v: 1 },
173+
],
174+
},
175+
"linear",
176+
at(0),
177+
);
178+
const curve = param.calls.find((c) => c.op === "curve");
179+
expect(curve, "a via-bent segment must be sampled, not ramped").toBeTruthy();
180+
if (curve?.op !== "curve") throw new Error("expected a curve");
181+
// Through the via point: 90% of the way up at the halfway mark.
182+
expect(curve.values[Math.floor(curve.values.length / 2)]).toBeGreaterThan(0.8);
183+
});
184+
155185
it("samples a log-scaled sweep, which a linear ramp would get wrong", () => {
156186
const { target, param } = fake();
157187
scheduleParamLane(
@@ -232,6 +262,238 @@ describe("scheduleParamLane", () => {
232262
});
233263
});
234264

265+
/**
266+
* An AudioParam with Chrome's own overlap rule, and its own cancel semantics.
267+
*
268+
* The distinction that matters: `cancelScheduledValues(t)` drops events at or
269+
* after `t` but leaves a value curve that is already running — the spec only
270+
* special-cases an in-progress curve for `cancelAndHoldAtTime`, which truncates
271+
* it. Schedule a curve inside one that is still live and the browser throws.
272+
*/
273+
class OverlapAwareParam {
274+
curves: { time: number; duration: number }[] = [];
275+
value = 0;
276+
setValueAtTime(): void {}
277+
linearRampToValueAtTime(): void {}
278+
setValueCurveAtTime(_values: Float32Array, time: number, duration: number): void {
279+
const clash = this.curves.find((c) => time < c.time + c.duration && time + duration > c.time);
280+
if (clash) {
281+
throw new Error(
282+
`Failed to execute 'setValueCurveAtTime' on 'AudioParam': ` +
283+
`setValueCurveAtTime(..., ${time}, ${duration}) overlaps ` +
284+
`setValueCurveAtTime(..., ${clash.time}, ${clash.duration})`,
285+
);
286+
}
287+
this.curves.push({ time, duration });
288+
}
289+
cancelScheduledValues(time: number): void {
290+
// Events at or after the cancel; a curve already under way is untouched.
291+
this.curves = this.curves.filter((c) => c.time < time);
292+
}
293+
cancelAndHoldAtTime(time: number): void {
294+
this.curves = this.curves
295+
.filter((c) => c.time < time)
296+
.map((c) => ({ time: c.time, duration: Math.min(c.duration, time - c.time) }));
297+
}
298+
}
299+
300+
describe("rescheduling over a curve that is still playing", () => {
301+
const bent: HfAutomationLane = {
302+
target: "volume",
303+
points: [
304+
{ t: 0, v: 1, curve: 1 },
305+
{ t: 8, v: 0.2, curve: 1 },
306+
{ t: 12, v: 1 },
307+
],
308+
};
309+
310+
it("takes over from an in-progress curve instead of throwing", () => {
311+
// Two schedule passes a few milliseconds apart is ordinary: an attribute edit
312+
// lands, the graph is re-parameterised, and the envelope is re-aimed at the
313+
// live playhead. The second pass has to displace the curve the first one
314+
// started, which `cancelScheduledValues` does not do for a curve that is
315+
// already running — the browser then refuses the new curve outright.
316+
const param = new OverlapAwareParam();
317+
const target = { param: param as unknown as AudioParam };
318+
scheduleParamLane([target], bent, "linear", at(0, 1, 9.109333));
319+
expect(() =>
320+
scheduleParamLane([target], bent, "linear", at(0.005334, 1, 9.114667)),
321+
).not.toThrow();
322+
});
323+
324+
it("survives a burst of reschedules, as a dragged knob produces", () => {
325+
const param = new OverlapAwareParam();
326+
const target = { param: param as unknown as AudioParam };
327+
for (let i = 0; i < 12; i++) {
328+
const when = 9.1 + i * 0.005;
329+
expect(() =>
330+
scheduleParamLane([target], bent, "linear", at(i * 0.005, 1, when)),
331+
).not.toThrow();
332+
}
333+
});
334+
});
335+
336+
describe("a curve the browser refuses", () => {
337+
/**
338+
* A param that rejects any curve overlapping one it has been given, and — like
339+
* Chrome — keeps counting a held curve's original span. Holding stops what is
340+
* audible; it does not free the slot for overlap checking.
341+
*/
342+
class UnforgivingParam {
343+
curves: { time: number; duration: number }[] = [];
344+
ramps: { time: number; value: number }[] = [];
345+
sets: { time: number; value: number }[] = [];
346+
value = 0;
347+
setValueAtTime(value: number, time: number): void {
348+
this.sets.push({ time, value });
349+
}
350+
linearRampToValueAtTime(value: number, time: number): void {
351+
this.ramps.push({ time, value });
352+
}
353+
setValueCurveAtTime(_v: Float32Array, time: number, duration: number): void {
354+
const clash = this.curves.find((c) => time < c.time + c.duration && time + duration > c.time);
355+
if (clash) {
356+
throw new Error(
357+
`Failed to execute 'setValueCurveAtTime' on 'AudioParam': ` +
358+
`setValueCurveAtTime(..., ${time}, ${duration}) overlaps ` +
359+
`setValueCurveAtTime(..., ${clash.time}, ${clash.duration})`,
360+
);
361+
}
362+
this.curves.push({ time, duration });
363+
}
364+
// Measured against Chrome, in a live running context and in an offline one
365+
// suspended mid-curve: either cancel frees the span of a curve already under
366+
// way, so only a write with no cancel at all is refused.
367+
cancelScheduledValues(time: number): void {
368+
this.curves = this.curves.filter((c) => c.time + c.duration < time);
369+
}
370+
cancelAndHoldAtTime(time: number): void {
371+
this.cancelScheduledValues(time);
372+
}
373+
}
374+
375+
const bent: HfAutomationLane = {
376+
target: "volume",
377+
points: [
378+
{ t: 0, v: 1, curve: 1 },
379+
{ t: 0.25, v: 0.4, curve: 1 },
380+
{ t: 0.6, v: 1, curve: 1 },
381+
{ t: 1.2, v: 0.3 },
382+
],
383+
};
384+
385+
it("reschedules mid-playback with its curves intact", () => {
386+
// Applying a carve while the transport runs reschedules into curves that are
387+
// already playing — one render quantum apart is the common case. Cancelling
388+
// the parameter first is what lets the new pass keep its shape instead of
389+
// being refused; this does not discriminate the strength of the cancel, which
390+
// the no-cancel test below is for.
391+
const param = new UnforgivingParam();
392+
const target = { param: param as unknown as AudioParam };
393+
scheduleParamLane([target], bent, "linear", at(0, 1, 1492.016));
394+
const first = param.curves.length;
395+
expect(() =>
396+
scheduleParamLane([target], bent, "linear", at(0.005333, 1, 1492.021333)),
397+
).not.toThrow();
398+
expect(param.curves.length).toBeGreaterThan(0);
399+
// Curves, not ramps: nothing had to be degraded.
400+
expect(param.ramps).toHaveLength(0);
401+
expect(first).toBeGreaterThan(0);
402+
});
403+
404+
it("still degrades to a ramp where the span cannot be freed at all", () => {
405+
// The last line of defence, for a hypothetical param that refuses to give the
406+
// span up. No engine has been measured behaving this way; it stands in for the
407+
// unexplained curve-over-curve refusals reported from the field. The envelope
408+
// loses a bend rather than the exception escaping and abandoning the rest.
409+
class ImmovableParam extends UnforgivingParam {
410+
override cancelScheduledValues(): void {
411+
// Nothing is ever freed.
412+
}
413+
}
414+
const param = new ImmovableParam();
415+
const target = { param: param as unknown as AudioParam };
416+
scheduleParamLane([target], bent, "linear", at(0, 1, 100));
417+
expect(() =>
418+
scheduleParamLane([target], bent, "linear", at(0.005333, 1, 100.005333)),
419+
).not.toThrow();
420+
expect(param.ramps.length).toBeGreaterThan(0);
421+
});
422+
});
423+
424+
describe("a curve that starts at this very instant", () => {
425+
/**
426+
* Chrome's behaviour at the boundary, which is where this bit: holding at a
427+
* time does not free a curve that *begins* at that time, so both a fresh curve
428+
* and a plain `.value` write at the same instant are refused. Only a cancel
429+
* clears it.
430+
*/
431+
class BoundaryParam {
432+
curves: { time: number; duration: number }[] = [];
433+
#value = 0;
434+
constructor(private clock: { currentTime: number }) {}
435+
get value(): number {
436+
return this.#value;
437+
}
438+
set value(v: number) {
439+
const t = this.clock.currentTime;
440+
const clash = this.curves.find((c) => t >= c.time && t <= c.time + c.duration);
441+
if (clash) {
442+
throw new Error(
443+
`Failed to set the 'value' property on 'AudioParam': setValueAtTime(${v}, ${t}) ` +
444+
`overlaps setValueCurveAtTime(..., ${clash.time}, ${clash.duration})`,
445+
);
446+
}
447+
this.#value = v;
448+
}
449+
setValueAtTime(v: number): void {
450+
this.#value = v;
451+
}
452+
linearRampToValueAtTime(): void {}
453+
setValueCurveAtTime(_v: Float32Array, time: number, duration: number): void {
454+
this.curves.push({ time, duration });
455+
}
456+
// As Chrome behaves: either cancel frees a running curve's span.
457+
cancelScheduledValues(time: number): void {
458+
this.curves = this.curves.filter((c) => c.time + c.duration < time);
459+
}
460+
cancelAndHoldAtTime(time: number): void {
461+
this.cancelScheduledValues(time);
462+
}
463+
}
464+
465+
const bent: HfAutomationLane = {
466+
target: "volume",
467+
points: [
468+
{ t: 0, v: 1, curve: 1 },
469+
{ t: 0.04, v: 0.2, curve: 1 },
470+
{ t: 4, v: 1 },
471+
],
472+
};
473+
474+
it("clears the booked span so the next pass can schedule at all", () => {
475+
const clock = { currentTime: 91.069 };
476+
const param = new BoundaryParam(clock);
477+
const target = { param: param as unknown as AudioParam };
478+
scheduleParamLane([target], bent, "linear", at(0, 1, 91.069));
479+
expect(() => scheduleParamLane([target], bent, "linear", at(0, 1, 91.069))).not.toThrow();
480+
});
481+
482+
it("leaves the parameter writable, which is what a chain edit does next", () => {
483+
// The re-parameterise after an edit writes every knob straight onto its param.
484+
// Landing inside a span the scheduler booked at this instant is the error the
485+
// console kept reporting, with the gain stage's own unity value in it.
486+
const clock = { currentTime: 91.069 };
487+
const param = new BoundaryParam(clock);
488+
const target = { param: param as unknown as AudioParam };
489+
scheduleParamLane([target], bent, "linear", at(0, 1, 91.069));
490+
clearParamLane([target]);
491+
expect(() => {
492+
(param as unknown as { value: number }).value = 1;
493+
}).not.toThrow();
494+
});
495+
});
496+
235497
describe("cancelParamLane", () => {
236498
it("holds the value the envelope had reached rather than snapping back", () => {
237499
const { target, param } = fake();

0 commit comments

Comments
 (0)