@@ -68,18 +68,6 @@ function readChain(el: { getAttribute?(name: string): string | null }): {
6868 }
6969}
7070
71- /**
72- * An element's automation lanes, bound to whatever chain it carries.
73- *
74- * FX lanes need the chain to resolve their target's range, so they are dropped
75- * for an element with no chain; a volume lane is always readable.
76- */
77- export function readElementAutomation ( el : {
78- getAttribute ?( name : string ) : string | null ;
79- } ) : HfAutomation {
80- return readAutomation ( el , readChain ( el ) . chain ) ;
81- }
82-
8371/**
8472 * Splice an element's FX chain between a decoded source and its gain stage.
8573 *
@@ -88,8 +76,8 @@ export function readElementAutomation(el: {
8876 * point where effects belong — capturing the element would process a stream
8977 * nothing is listening to.
9078 *
91- * Returns null when the element carries no chain, leaving the original
92- * source-to-gain connection in place .
79+ * A track with no chain is wired straight through, but still watched: adding its
80+ * first effect is then heard without rescheduling the source .
9381 *
9482 * With `timing`, the element's automation lanes are scheduled onto the built
9583 * effects as AudioParam ramps, and rescheduled when the attribute is edited.
@@ -102,10 +90,6 @@ export function attachElementFxChain(
10290 timing ?: AutomationTiming ,
10391) : { dispose ( ) : void } | null {
10492 const { chain } = readChain ( el ) ;
105- if ( chain . nodes . length === 0 ) {
106- source . connect ( destination ) ;
107- return null ;
108- }
10993
11094 // An AudioWorkletNode cannot be constructed before its processor is
11195 // registered — it throws, and the whole chain is lost. So when the chain
@@ -137,21 +121,56 @@ export function attachElementFxChain(
137121 } ;
138122 }
139123
140- let handle : FxChainHandle ;
141- try {
142- handle = buildFxChain ( ctx , chain ) ;
143- } catch {
144- // A chain we cannot realise plays dry rather than silencing the track.
145- source . connect ( destination ) ;
146- return null ;
147- }
124+ // Null means the source runs straight into its gain: an empty chain, or one
125+ // that could not be realised. Mutable because a structural edit swaps the
126+ // whole graph rather than re-parameterising it.
127+ let handle : FxChainHandle | null = null ;
128+ let automated : FxParamTarget [ ] = [ ] ;
129+
130+ /** Take the current graph out of the path, leaving the source connected dry. */
131+ const detach = ( ) : void => {
132+ try {
133+ if ( handle ) {
134+ source . disconnect ( handle . input ) ;
135+ handle . output . disconnect ( destination ) ;
136+ handle . dispose ( ) ;
137+ } else {
138+ source . disconnect ( destination ) ;
139+ }
140+ } catch {
141+ // Already disconnected; nothing to unwind.
142+ }
143+ handle = null ;
144+ } ;
145+
146+ /**
147+ * Put `next` in the signal path.
148+ *
149+ * A chain that cannot be realised — an unregistered worklet, an unknown
150+ * effect — plays dry rather than silencing the track.
151+ */
152+ const attach = ( next : HfAudioFxChain ) : void => {
153+ if ( next . nodes . length === 0 ) {
154+ source . connect ( destination ) ;
155+ return ;
156+ }
157+ try {
158+ const built = buildFxChain ( ctx , next ) ;
159+ source . connect ( built . input ) ;
160+ built . output . connect ( destination ) ;
161+ handle = built ;
162+ } catch {
163+ source . connect ( destination ) ;
164+ }
165+ } ;
148166
149- source . connect ( handle . input ) ;
150- handle . output . connect ( destination ) ;
167+ const scheduleFor = ( next : HfAudioFxChain , at : AutomationTiming | null ) : void => {
168+ automated =
169+ at && handle ? scheduleChainAutomation ( readAutomation ( el , next ) , next , handle . nodes , at ) : [ ] ;
170+ } ;
151171
152- let automated : FxParamTarget [ ] = timing
153- ? scheduleChainAutomation ( readAutomation ( el , chain ) , chain , handle . nodes , timing )
154- : [ ] ;
172+ attach ( chain ) ;
173+ scheduleFor ( chain , timing ?? null ) ;
155174
156175 /**
157176 * Re-aim the envelope at the live playhead. An edit lands mid-playback, so
@@ -171,14 +190,31 @@ export function attachElementFxChain(
171190 const at = timingNow ( ) ;
172191 if ( ! at ) return ;
173192 cancelParamLane ( automated , at . scheduledAt ) ;
174- automated = scheduleChainAutomation ( readAutomation ( el , next ) , next , handle . nodes , at ) ;
193+ scheduleFor ( next , at ) ;
194+ } ;
195+
196+ /**
197+ * Rebuild the graph for a shape change — an effect added, removed, bypassed,
198+ * or a filter's pole count switched — while the source keeps playing.
199+ *
200+ * The source node is untouched, so the audio does not restart; only the
201+ * effects between it and its gain are replaced. Doing this here is what keeps
202+ * a structural edit from needing a composition reload, which is what made the
203+ * audio audibly chop.
204+ */
205+ const rebuild = ( next : HfAudioFxChain ) : void => {
206+ const at = timingNow ( ) ;
207+ cancelParamLane ( automated , at ?. scheduledAt ?? 0 ) ;
208+ detach ( ) ;
209+ attach ( next ) ;
210+ scheduleFor ( next , at ) ;
175211 } ;
176212
177- // Follow the attribute while the source plays, so dragging a knob is heard
178- // without rescheduling the track. Values -only changes re-parameterise the
179- // running graph and land on the next 128-sample quantum; a shape change
180- // (effect added, bypassed, pole count) cannot be patched in place and waits
181- // for the next schedule rather than cutting the audio mid-play .
213+ // Follow the attribute while the source plays, so editing a chain is heard
214+ // without rescheduling the track. A values -only change re-parameterises the
215+ // running graph and lands on the next 128-sample quantum; anything structural
216+ // swaps the effects between the source and its gain, leaving the source — and
217+ // so the playing audio — alone .
182218 let observer : MutationObserver | null = null ;
183219 const target = el as unknown as Node ;
184220 if (
@@ -187,11 +223,10 @@ export function attachElementFxChain(
187223 ) {
188224 observer = new MutationObserver ( ( ) => {
189225 const next = readChain ( el ) ;
190- if ( next . chain . nodes . length === 0 ) return ;
191- handle . update ( next . chain ) ;
192- // Values pushed by `update` would fight a running envelope, so the lanes
193- // are re-scheduled on top of them from the current playhead.
194- rescheduleAutomation ( next . chain ) ;
226+ // `update` reports false when the change is structural rather than a new
227+ // set of values, which is the signal to swap the graph.
228+ if ( ! handle || ! handle . update ( next . chain ) ) rebuild ( next . chain ) ;
229+ else rescheduleAutomation ( next . chain ) ;
195230 } ) ;
196231 observer . observe ( target , {
197232 attributes : true ,
@@ -205,7 +240,7 @@ export function attachElementFxChain(
205240 if ( automated . length > 0 ) {
206241 cancelParamLane ( automated , typeof ctx . currentTime === "number" ? ctx . currentTime : 0 ) ;
207242 }
208- handle . dispose ( ) ;
243+ handle ? .dispose ( ) ;
209244 } ,
210245 } ;
211246}
0 commit comments