22import { describe , expect , it } from "vitest" ;
33import {
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+
235497describe ( "cancelParamLane" , ( ) => {
236498 it ( "holds the value the envelope had reached rather than snapping back" , ( ) => {
237499 const { target, param } = fake ( ) ;
0 commit comments