From 05d081c61da7b0cfc2a8c74a4a520bd5ffd7cc6b Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Sat, 8 Aug 2026 11:06:20 -0700 Subject: [PATCH] fix(core): dispose a clip's FX graph when it ends, not only when it is stopped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `ended` listener spliced the source out of `_activeSources` and restored the element's muted flag, but never disposed the FX handle — and the splice is what made it unreachable, because `stopAll()` disposes by walking that same array. So every clip that finished naturally leaked its graph for the rest of the session. The leak is not just memory. Each stale handle keeps a MutationObserver bound to `data-fx-chain`/`data-automation`, so every later panel edit runs rebuild() against a dead source: a fresh graph per abandoned clip, including a ~576 KB reverb impulse and chorus/phaser oscillators that are started and never stopped. Play a five-clip composition three times and fifteen observers answer every subsequent knob turn. At this point in the stack `attachElementFxChain` has no chainless early return — it watches every audio clip, chain or not — so this leaked for all of them, not only the ones carrying effects. Disposal is skipped when the entry was already gone: `stopAll()` disposes its own, and `stop()` is what fires this event. --- .../core/src/runtime/webAudioTransport.test.ts | 13 +++++++++++++ packages/core/src/runtime/webAudioTransport.ts | 15 +++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/packages/core/src/runtime/webAudioTransport.test.ts b/packages/core/src/runtime/webAudioTransport.test.ts index 15f1825a13..f1597ccc8b 100644 --- a/packages/core/src/runtime/webAudioTransport.test.ts +++ b/packages/core/src/runtime/webAudioTransport.test.ts @@ -388,6 +388,19 @@ describe("WebAudioTransport", () => { expect(transport.isActive()).toBe(false); }); + it("disposes the FX graph when a clip ends naturally", async () => { + // stopAll() disposes by walking _activeSources, and the splice above had + // already removed this entry — so the handle, its MutationObserver and any + // running LFO survived the clip for the rest of the session. + const { transport, mock, gen } = setupTransport(100); + await transport.schedulePlayback(mockEl, mockBuffer, 0, 0, 0, 1, gen); + + mock.sourceNode._fireEnded(); + + expect(mock.sourceNode.disconnect).toHaveBeenCalled(); + expect(mock.gainNode.disconnect).toHaveBeenCalled(); + }); + it("registers onended listener on the sourceNode", async () => { const { transport, mock, gen } = setupTransport(100); diff --git a/packages/core/src/runtime/webAudioTransport.ts b/packages/core/src/runtime/webAudioTransport.ts index fedcdca0bf..2ce3ea4bba 100644 --- a/packages/core/src/runtime/webAudioTransport.ts +++ b/packages/core/src/runtime/webAudioTransport.ts @@ -243,6 +243,21 @@ export class WebAudioTransport { if (idx !== -1) { this._activeSources.splice(idx, 1); el.muted = priorMuted; + // The graph goes with it. Splicing alone left the FX handle alive and + // then UNREACHABLE — stopAll() disposes by walking this array, which + // the splice just emptied of this entry. Every clip that finished + // naturally leaked its MutationObserver for the session, and each one + // still answered later `data-fx-chain` edits by rebuilding a whole + // graph (impulse response, chorus/phaser oscillators started and never + // stopped) around a dead source. Not disposed when idx is -1: stopAll() + // has already done it, and `stop()` is what fired this event. + try { + sourceNode.disconnect(); + fx?.dispose(); + gainNode.disconnect(); + } catch { + // Already torn down. + } if (this._activeSources.length === 0) this._paused = true; } });