@@ -58,22 +58,59 @@ export function synthesizeReverbImpulse(
5858 return out ;
5959}
6060
61+ /**
62+ * Where an automation lane writes when it drives one knob.
63+ *
64+ * A knob is not always one AudioParam. A wet/dry mix is two gains moving in
65+ * opposition, and a knob in milliseconds drives a delay time in seconds, so
66+ * each target carries its own mapping out of the knob's declared unit.
67+ */
68+ export interface FxParamTarget {
69+ param : AudioParam ;
70+ map ?: ( value : number ) => number ;
71+ }
72+
6173export interface FxNodeHandle {
6274 input : AudioNode ;
6375 output : AudioNode ;
6476 update ( params : HfAudioFxParamValues ) : void ;
77+ /**
78+ * AudioParams behind the knobs the registry marks `automatable`, keyed by
79+ * parameter key. Absent for a node whose values cannot be scheduled.
80+ */
81+ automation ?: Record < string , FxParamTarget [ ] > ;
6582 dispose ( ) : void ;
6683}
6784
6885type Builder = ( ctx : BaseAudioContext , p : HfAudioFxParamValues ) => FxNodeHandle ;
6986
7087const n = ( v : number | string | undefined ) : number => ( typeof v === "number" ? v : Number ( v ?? 0 ) ) ;
7188
89+ /** Milliseconds on the knob, seconds on the AudioParam. */
90+ const msToSec = ( v : number ) : number => v / 1000 ;
91+
92+ /** A wet/dry pair: the dry side is whatever the wet side is not. */
93+ function mixTargets ( wet : AudioParam , dry : AudioParam ) : FxParamTarget [ ] {
94+ return [ { param : wet } , { param : dry , map : ( v ) => 1 - v } ] ;
95+ }
96+
7297/** A node that is its own input and output and has nothing to tear down. */
73- function simple ( node : AudioNode , update : ( p : HfAudioFxParamValues ) => void ) : FxNodeHandle {
74- return { input : node , output : node , update, dispose : ( ) => node . disconnect ( ) } ;
98+ function simple (
99+ node : AudioNode ,
100+ update : ( p : HfAudioFxParamValues ) => void ,
101+ automation ?: Record < string , FxParamTarget [ ] > ,
102+ ) : FxNodeHandle {
103+ return { input : node , output : node , update, automation, dispose : ( ) => node . disconnect ( ) } ;
75104}
76105
106+ /**
107+ * Filter types whose Q a BiquadFilterNode actually reads. The spec leaves it
108+ * unused for shelving filters, so the registry offers no shelf Q and the graph
109+ * must expose none either — the exposure invariant would otherwise advertise an
110+ * AudioParam for a knob nobody can set.
111+ */
112+ const USES_Q : ReadonlySet < BiquadFilterType > = new Set ( [ "peaking" , "highpass" , "lowpass" ] ) ;
113+
77114function biquad ( type : BiquadFilterType , useGain : boolean ) : Builder {
78115 return ( ctx , p ) => {
79116 const f = ctx . createBiquadFilter ( ) ;
@@ -84,7 +121,11 @@ function biquad(type: BiquadFilterType, useGain: boolean): Builder {
84121 if ( useGain ) f . gain . value = n ( v . gain ) ;
85122 } ;
86123 apply ( p ) ;
87- return simple ( f , apply ) ;
124+ return simple ( f , apply , {
125+ frequency : [ { param : f . frequency } ] ,
126+ ...( USES_Q . has ( type ) ? { q : [ { param : f . Q } ] } : { } ) ,
127+ ...( useGain ? { gain : [ { param : f . gain } ] } : { } ) ,
128+ } ) ;
88129 } ;
89130}
90131
@@ -102,6 +143,8 @@ function onePoleBuilder(kind: "highpass" | "lowpass"): Builder {
102143 ? ctx . createIIRFilter ( [ 1 / ( 1 + k ) , - 1 / ( 1 + k ) ] , [ 1 , ( k - 1 ) / ( k + 1 ) ] )
103144 : ctx . createIIRFilter ( [ k / ( 1 + k ) , k / ( 1 + k ) ] , [ 1 , ( k - 1 ) / ( k + 1 ) ] ) ;
104145 // IIRFilterNode coefficients are immutable; the caller rebuilds on change.
146+ // Nothing here is schedulable either, so a frequency lane on a one-pole
147+ // filter has nowhere to write — the scheduler skips what is not exposed.
105148 return simple ( node , ( ) => { } ) ;
106149 } ;
107150}
@@ -154,6 +197,9 @@ const waveshaper: Builder = (ctx, p) => {
154197 input : preGain ,
155198 output : postGain ,
156199 update : apply ,
200+ // The curve itself is rebuilt wholesale, but the make-up gain after it is
201+ // an ordinary AudioParam.
202+ automation : { output : [ { param : postGain . gain , map : ( v ) => Math . pow ( 10 , v / 20 ) } ] } ,
157203 dispose : ( ) => {
158204 preGain . disconnect ( ) ;
159205 ws . disconnect ( ) ;
@@ -185,6 +231,11 @@ const delayFeedback: Builder = (ctx, p) => {
185231 input,
186232 output : out ,
187233 update : apply ,
234+ automation : {
235+ time : [ { param : dl . delayTime , map : ( v ) => Math . min ( 5 , msToSec ( v ) ) } ] ,
236+ feedback : [ { param : fb . gain } ] ,
237+ mix : mixTargets ( wet . gain , dry . gain ) ,
238+ } ,
188239 dispose : ( ) => [ input , out , dl , fb , wet , dry ] . forEach ( ( x ) => x . disconnect ( ) ) ,
189240 } ;
190241} ;
@@ -213,6 +264,12 @@ const chorusLfo: Builder = (ctx, p) => {
213264 input,
214265 output : out ,
215266 update : apply ,
267+ automation : {
268+ delay : [ { param : dl . delayTime , map : msToSec } ] ,
269+ depth : [ { param : depth . gain , map : msToSec } ] ,
270+ speed : [ { param : lfo . frequency } ] ,
271+ mix : mixTargets ( wet . gain , dry . gain ) ,
272+ } ,
216273 dispose : ( ) => {
217274 try {
218275 lfo . stop ( ) ;
@@ -279,6 +336,13 @@ const allpassPhaser: Builder = (ctx, p) => {
279336 input,
280337 output : out ,
281338 update : apply ,
339+ // `delay` and `decay` set the sweep centre, which feeds every stage's
340+ // frequency at once — not one knob, one param — so they stay unautomated.
341+ automation : {
342+ speed : [ { param : lfo . frequency } ] ,
343+ in_gain : [ { param : dry . gain } ] ,
344+ out_gain : [ { param : wet . gain } ] ,
345+ } ,
282346 dispose : ( ) => {
283347 try {
284348 lfo . stop ( ) ;
@@ -318,6 +382,9 @@ const convolver: Builder = (ctx, p) => {
318382 input,
319383 output : out ,
320384 update : apply ,
385+ // Size and damping regenerate the impulse response, so only the wet/dry
386+ // balance is schedulable.
387+ automation : { wet : [ { param : wet . gain } ] , dry : [ { param : dry . gain } ] } ,
321388 dispose : ( ) => [ input , out , conv , wet , dry ] . forEach ( ( x ) => x . disconnect ( ) ) ,
322389 } ;
323390} ;
0 commit comments