From bb5f49d2602b63307429ae3b5e444d654c906324 Mon Sep 17 00:00:00 2001 From: Konstantinos Tzoulas <10597660+tzzo@users.noreply.github.com> Date: Wed, 23 Sep 2026 09:18:43 +0300 Subject: [PATCH] fix(worklet-base): Stop the processor after a dispose message process() always returned true, so a SoundTouch processor stayed alive for the life of the AudioContext even after every connection to its node was gone: it kept rendering and kept its buffers. Apps that create a fresh node per track (to start from empty FIFOs) accumulated one live processor per track. The processor now flips a flag on { type: 'dispose' } and returns false from the next render quantum, so the browser can release it. SoundTouchNode, PhaseVocoderNode and FormantCorrectionNode gain dispose(), which posts the message and disconnects the node. Co-Authored-By: Claude Fable 5.1 --- packages/audio-worklet/README.md | 10 ++++++++ .../audio-worklet/src/SoundTouchNode.spec.ts | 15 ++++++++++++ packages/audio-worklet/src/SoundTouchNode.ts | 15 ++++++++++++ packages/audio-worklet/src/processor.spec.ts | 24 +++++++++++++++++++ packages/formant-correction-worklet/README.md | 1 + .../src/FormantCorrectionNode.spec.ts | 15 ++++++++++++ .../src/FormantCorrectionNode.ts | 15 ++++++++++++ packages/phase-vocoder-worklet/README.md | 1 + .../src/PhaseVocoderNode.spec.ts | 15 ++++++++++++ .../src/PhaseVocoderNode.ts | 15 ++++++++++++ packages/worklet-base/README.md | 6 ++++- .../src/SoundTouchProcessorBase.spec.ts | 20 +++++++++++++++- .../src/SoundTouchProcessorBase.ts | 18 ++++++++++++-- packages/worklet-base/src/index.ts | 1 + packages/worklet-base/src/types.ts | 11 ++++++++- 15 files changed, 177 insertions(+), 5 deletions(-) diff --git a/packages/audio-worklet/README.md b/packages/audio-worklet/README.md index ab0776a..2203be9 100644 --- a/packages/audio-worklet/README.md +++ b/packages/audio-worklet/README.md @@ -340,6 +340,16 @@ import type { ProcessorMetrics } from '@soundtouchjs/audio-worklet'; | `outputPeak` | Peak absolute value of the last output block (both channels) | | `timestamp` | `performance.now()` on the main thread when metrics arrived | +## Disposing a node + +An `AudioWorkletProcessor` stays alive for as long as its `process()` returns `true`, and the SoundTouch processor does until it is told to stop — so a node you `disconnect()` and drop keeps rendering, and keeps its buffers, until the `AudioContext` closes. If you create a fresh node per track, dispose of the old one: + +```ts +stNode.dispose(); +``` + +`dispose()` posts a `dispose` message to the processor and disconnects the node. The processor returns `false` from its next render quantum and can be garbage-collected; the node cannot be reused. + ## Mono input and output The processor supports both mono input and mono output without extra configuration. diff --git a/packages/audio-worklet/src/SoundTouchNode.spec.ts b/packages/audio-worklet/src/SoundTouchNode.spec.ts index 82df135..ea5a735 100644 --- a/packages/audio-worklet/src/SoundTouchNode.spec.ts +++ b/packages/audio-worklet/src/SoundTouchNode.spec.ts @@ -13,6 +13,7 @@ const lastCtorOptions: { value: MockNodeOptions | undefined } = { type MessageListener = (event: MessageEvent) => void; class MockAudioWorkletNode { + disconnect = vi.fn(); parameters: Map; port: { postMessage: ReturnType; @@ -152,6 +153,20 @@ describe('SoundTouchNode', () => { }); }); + it('sends dispose message and disconnects the node', async () => { + const { SoundTouchNode } = await import('./index.js'); + const node = new SoundTouchNode({ context: {} as BaseAudioContext }); + + node.dispose(); + + const mock = node as unknown as { + port: { postMessage: ReturnType }; + disconnect: ReturnType; + }; + expect(mock.port.postMessage).toHaveBeenCalledWith({ type: 'dispose' }); + expect(mock.disconnect).toHaveBeenCalledOnce(); + }); + describe('outputChannelCount option', () => { it('defaults to stereo (outputChannelCount [2]) when not specified', async () => { const { SoundTouchNode } = await import('./index.js'); diff --git a/packages/audio-worklet/src/SoundTouchNode.ts b/packages/audio-worklet/src/SoundTouchNode.ts index 0ce67de..7efe78f 100644 --- a/packages/audio-worklet/src/SoundTouchNode.ts +++ b/packages/audio-worklet/src/SoundTouchNode.ts @@ -269,4 +269,19 @@ export class SoundTouchNode extends AudioWorkletNode { params, }); } + + /** + * Stops the render-thread processor and disconnects the node. + * + * @remarks + * An `AudioWorkletProcessor` is kept alive for as long as its `process()` + * returns `true`, which this one does until told otherwise — so a node that + * is merely disconnected and dropped keeps rendering, and keeps its buffers, + * until the `AudioContext` closes. Call this when the node is no longer + * needed. The node cannot be reused afterwards. + */ + dispose(): void { + this.port.postMessage({ type: 'dispose' }); + this.disconnect(); + } } diff --git a/packages/audio-worklet/src/processor.spec.ts b/packages/audio-worklet/src/processor.spec.ts index 13f6253..9394e28 100644 --- a/packages/audio-worklet/src/processor.spec.ts +++ b/packages/audio-worklet/src/processor.spec.ts @@ -170,6 +170,30 @@ describe('processor', () => { }); }); + it('stops rendering after a dispose message', async () => { + await import('./processor.js'); + const instance = new registeredCtor!({ + processorOptions: { sampleBufferType: 'circular' }, + }) as unknown as { + port: { onmessage: ((event: { data: unknown }) => void) | null }; + process: RegisteredProcessorCtor['prototype']['process']; + }; + const params = { + pitch: new Float32Array([1]), + pitchSemitones: new Float32Array([0]), + playbackRate: new Float32Array([1]), + }; + const inputLeft = new Float32Array([1, 2]); + const outputs = [[new Float32Array(2), new Float32Array(2)]]; + outputFrameCount = 0; + + expect(instance.process([[inputLeft]], outputs, params)).toBe(true); + + instance.port.onmessage?.({ data: { type: 'dispose' } }); + + expect(instance.process([[inputLeft]], outputs, params)).toBe(false); + }); + it('logs and recovers when runtime strategy update handlers throw', async () => { const infoSpy = vi.spyOn(console, 'info').mockImplementation(() => {}); setInterpolationStrategy.mockImplementationOnce(() => { diff --git a/packages/formant-correction-worklet/README.md b/packages/formant-correction-worklet/README.md index ab02b43..ec34e2e 100644 --- a/packages/formant-correction-worklet/README.md +++ b/packages/formant-correction-worklet/README.md @@ -149,6 +149,7 @@ Extends `AudioWorkletNode`. Provides the same API as `SoundTouchNode` plus a `fo | `setInterpolationStrategy(strategy)` | Switches interpolation strategy at runtime. | | `setInterpolationStrategyParams(params)` | Updates parameters for the active strategy. | | `setStretchParameters(params)` | Applies WSOLA timing parameters. | +| `dispose()` | Stops the render-thread processor and disconnects the node. The node cannot be reused. | #### Processor observability diff --git a/packages/formant-correction-worklet/src/FormantCorrectionNode.spec.ts b/packages/formant-correction-worklet/src/FormantCorrectionNode.spec.ts index a16c97d..5d613c3 100644 --- a/packages/formant-correction-worklet/src/FormantCorrectionNode.spec.ts +++ b/packages/formant-correction-worklet/src/FormantCorrectionNode.spec.ts @@ -13,6 +13,7 @@ const lastCtorOptions: { value: MockNodeOptions | undefined } = { type MessageListener = (event: MessageEvent) => void; class MockAudioWorkletNode { + disconnect = vi.fn(); parameters: Map; port: { postMessage: ReturnType; @@ -135,6 +136,20 @@ describe('FormantCorrectionNode', () => { }); }); + it('sends dispose message and disconnects the node', async () => { + const { FormantCorrectionNode } = await import('./index.js'); + const node = new FormantCorrectionNode({ context: {} as BaseAudioContext }); + + node.dispose(); + + const mock = node as unknown as { + port: { postMessage: ReturnType }; + disconnect: ReturnType; + }; + expect(mock.port.postMessage).toHaveBeenCalledWith({ type: 'dispose' }); + expect(mock.disconnect).toHaveBeenCalledOnce(); + }); + describe('outputChannelCount option', () => { it('defaults to stereo when not specified', async () => { const { FormantCorrectionNode } = await import('./index.js'); diff --git a/packages/formant-correction-worklet/src/FormantCorrectionNode.ts b/packages/formant-correction-worklet/src/FormantCorrectionNode.ts index e12d784..4b0b82e 100644 --- a/packages/formant-correction-worklet/src/FormantCorrectionNode.ts +++ b/packages/formant-correction-worklet/src/FormantCorrectionNode.ts @@ -235,4 +235,19 @@ export class FormantCorrectionNode extends AudioWorkletNode { params, }); } + + /** + * Stops the render-thread processor and disconnects the node. + * + * @remarks + * An `AudioWorkletProcessor` is kept alive for as long as its `process()` + * returns `true`, which this one does until told otherwise — so a node that + * is merely disconnected and dropped keeps rendering, and keeps its buffers, + * until the `AudioContext` closes. Call this when the node is no longer + * needed. The node cannot be reused afterwards. + */ + dispose(): void { + this.port.postMessage({ type: 'dispose' }); + this.disconnect(); + } } diff --git a/packages/phase-vocoder-worklet/README.md b/packages/phase-vocoder-worklet/README.md index 3225328..48106e9 100644 --- a/packages/phase-vocoder-worklet/README.md +++ b/packages/phase-vocoder-worklet/README.md @@ -136,6 +136,7 @@ Extends `AudioWorkletNode`. Same API as `SoundTouchNode` with additional `fftSiz | `setInterpolationStrategy(strategy)` | Switches interpolation strategy at runtime. | | `setInterpolationStrategyParams(params)` | Updates parameters for the active strategy. | | `setStretchParameters(params)` | No-op for the phase vocoder (accepted for API parity with `SoundTouchNode`). | +| `dispose()` | Stops the render-thread processor and disconnects the node. The node cannot be reused. | #### Processor observability diff --git a/packages/phase-vocoder-worklet/src/PhaseVocoderNode.spec.ts b/packages/phase-vocoder-worklet/src/PhaseVocoderNode.spec.ts index ec62cb6..1facd54 100644 --- a/packages/phase-vocoder-worklet/src/PhaseVocoderNode.spec.ts +++ b/packages/phase-vocoder-worklet/src/PhaseVocoderNode.spec.ts @@ -13,6 +13,7 @@ const lastCtorOptions: { value: MockNodeOptions | undefined } = { type MessageListener = (event: MessageEvent) => void; class MockAudioWorkletNode { + disconnect = vi.fn(); parameters: Map; port: { postMessage: ReturnType; @@ -184,6 +185,20 @@ describe('PhaseVocoderNode', () => { }); }); + it('sends dispose message and disconnects the node', async () => { + const { PhaseVocoderNode } = await import('./index.js'); + const node = new PhaseVocoderNode({ context: {} as BaseAudioContext }); + + node.dispose(); + + const mock = node as unknown as { + port: { postMessage: ReturnType }; + disconnect: ReturnType; + }; + expect(mock.port.postMessage).toHaveBeenCalledWith({ type: 'dispose' }); + expect(mock.disconnect).toHaveBeenCalledOnce(); + }); + describe('outputChannelCount option', () => { it('defaults to stereo (outputChannelCount [2]) when not specified', async () => { const { PhaseVocoderNode } = await import('./index.js'); diff --git a/packages/phase-vocoder-worklet/src/PhaseVocoderNode.ts b/packages/phase-vocoder-worklet/src/PhaseVocoderNode.ts index 9727400..b07766a 100644 --- a/packages/phase-vocoder-worklet/src/PhaseVocoderNode.ts +++ b/packages/phase-vocoder-worklet/src/PhaseVocoderNode.ts @@ -287,4 +287,19 @@ export class PhaseVocoderNode extends AudioWorkletNode { params, }); } + + /** + * Stops the render-thread processor and disconnects the node. + * + * @remarks + * An `AudioWorkletProcessor` is kept alive for as long as its `process()` + * returns `true`, which this one does until told otherwise — so a node that + * is merely disconnected and dropped keeps rendering, and keeps its buffers, + * until the `AudioContext` closes. Call this when the node is no longer + * needed. The node cannot be reused afterwards. + */ + dispose(): void { + this.port.postMessage({ type: 'dispose' }); + this.disconnect(); + } } diff --git a/packages/worklet-base/README.md b/packages/worklet-base/README.md index 57f4973..e4d9116 100644 --- a/packages/worklet-base/README.md +++ b/packages/worklet-base/README.md @@ -64,6 +64,9 @@ node.port.postMessage({ type: 'setInterpolationStrategyParams', value: { ... } } // Update stretch parameters node.port.postMessage({ type: 'setStretchParameters', value: { ... } }); + +// Stop the processor for good: process() returns false from the next render quantum +node.port.postMessage({ type: 'dispose' }); ``` ## API @@ -113,7 +116,8 @@ Array of `AudioParamDescriptor` objects for the three standard k-rate parameters | `SetInterpolationStrategyMessage` | `{ type: 'setInterpolationStrategy', value: RateTransposerInterpolationStrategy }` | | `SetInterpolationStrategyParamsMessage` | `{ type: 'setInterpolationStrategyParams', value: InterpolationStrategyParams }` | | `SetStretchParametersMessage` | `{ type: 'setStretchParameters', value: StretchParameters }` | -| `ProcessorMessage` | Union of the three message types above. | +| `DisposeMessage` | `{ type: 'dispose' }` — stops the processor; `process()` returns `false` from the next render quantum. | +| `ProcessorMessage` | Union of the message types above. | | `ProcessCoreResult` | `{ outputRms: number, outputPeak: number }` | ## License diff --git a/packages/worklet-base/src/SoundTouchProcessorBase.spec.ts b/packages/worklet-base/src/SoundTouchProcessorBase.spec.ts index 38abd98..81bc288 100644 --- a/packages/worklet-base/src/SoundTouchProcessorBase.spec.ts +++ b/packages/worklet-base/src/SoundTouchProcessorBase.spec.ts @@ -195,6 +195,13 @@ describe('SoundTouchProcessorBase', () => { } as never); expect(proc['_pendingStretchParameters']).toEqual({ sequenceMs: 100 }); }); + + it('marks the processor disposed', () => { + const { TestProcessor } = makeConcreteClass(); + const proc = new TestProcessor('[Test]', { sampleRate: 44100 }); + proc.port.onmessage!({ data: { type: 'dispose' } } as never); + expect(proc['_disposed']).toBe(true); + }); }); describe('applyPendingRuntimeUpdates', () => { @@ -456,7 +463,7 @@ describe('SoundTouchProcessorBase', () => { }); describe('process (default implementation)', () => { - it('returns true always', () => { + it('returns true while live', () => { const { TestProcessor } = makeConcreteClass(); const proc = new TestProcessor('[Test]', { sampleRate: 44100 }); const result = proc.process([], makeOutputs(), makeParams()); @@ -480,6 +487,17 @@ describe('SoundTouchProcessorBase', () => { proc.process([], makeOutputs(), makeParams()); expect(onProcessComplete).not.toHaveBeenCalled(); }); + + it('returns false and skips the pipe once disposed', () => { + const { TestProcessor, onProcessComplete } = makeConcreteClass(); + const proc = new TestProcessor('[Test]', { sampleRate: 44100 }); + outputFrameCount = 128; + proc.port.onmessage!({ data: { type: 'dispose' } } as never); + const result = proc.process(makeInputs(), makeOutputs(), makeParams()); + expect(result).toBe(false); + expect(putSamples).not.toHaveBeenCalled(); + expect(onProcessComplete).not.toHaveBeenCalled(); + }); }); describe('beforePipeProcess (default hook)', () => { diff --git a/packages/worklet-base/src/SoundTouchProcessorBase.ts b/packages/worklet-base/src/SoundTouchProcessorBase.ts index 2e85964..e72e422 100644 --- a/packages/worklet-base/src/SoundTouchProcessorBase.ts +++ b/packages/worklet-base/src/SoundTouchProcessorBase.ts @@ -45,6 +45,7 @@ export abstract class SoundTouchProcessorBase extends AudioWorkletProcessor { private _pendingInterpolationStrategyParams: Partial | null = null; private _pendingStretchParameters: StretchParameters | null = null; + private _disposed = false; /** Label used in console messages (e.g. `'[SoundTouchProcessor]'`). */ protected readonly processorLabel: string; @@ -96,6 +97,10 @@ export abstract class SoundTouchProcessorBase extends AudioWorkletProcessor { if (port !== undefined) { port.onmessage = (event: MessageEvent) => { const message = event.data; + if (message.type === 'dispose') { + this._disposed = true; + return; + } if (message.type === 'set-interpolation-strategy') { this._pendingInterpolationStrategy = message.strategy; return; @@ -331,23 +336,32 @@ export abstract class SoundTouchProcessorBase extends AudioWorkletProcessor { protected abstract onProcessComplete(result: ProcessCoreResult): void; /** - * AudioWorkletProcessor render callback. Keeps the processor alive by always returning `true`. + * AudioWorkletProcessor render callback. Keeps the processor alive by returning + * `true` until a `dispose` message arrives. * * @remarks * The default implementation calls `applyPendingRuntimeUpdates`, `processCore`, * and `onProcessComplete`. Override only when the execution order must differ * (e.g. pre-pipe analysis steps not covered by `beforePipeProcess`). * + * A processor that keeps returning `true` is never released by the browser, + * even once every connection to its node is gone. After `{ type: 'dispose' }` + * is posted to the port, this returns `false` from the next render quantum so + * the processor can be garbage-collected. + * * @param inputs - AudioWorklet input buses. * @param outputs - AudioWorklet output buses. * @param parameters - k-rate AudioParam values. - * @returns Always `true` to keep the processor alive. + * @returns `true` while the processor is live, `false` once disposed. */ process( inputs: Float32Array[][], outputs: Float32Array[][], parameters: Record, ): boolean { + if (this._disposed) { + return false; + } this.applyPendingRuntimeUpdates(); const result = this.processCore(inputs, outputs, parameters); if (result !== null) { diff --git a/packages/worklet-base/src/index.ts b/packages/worklet-base/src/index.ts index 5c8bc4d..b99c04e 100644 --- a/packages/worklet-base/src/index.ts +++ b/packages/worklet-base/src/index.ts @@ -1,6 +1,7 @@ export { SoundTouchProcessorBase } from './SoundTouchProcessorBase.js'; export { STANDARD_PARAMETER_DESCRIPTORS } from './types.js'; export type { + DisposeMessage, ParameterDescriptor, ProcessCoreResult, ProcessorMessage, diff --git a/packages/worklet-base/src/types.ts b/packages/worklet-base/src/types.ts index 7da479a..cd91954 100644 --- a/packages/worklet-base/src/types.ts +++ b/packages/worklet-base/src/types.ts @@ -56,10 +56,19 @@ export interface SetStretchParametersMessage { params: StretchParameters; } +/** + * Tells the processor to stop for good: `process()` returns `false` from the + * next render quantum so the browser can release it. + */ +export interface DisposeMessage { + type: 'dispose'; +} + export type ProcessorMessage = | SetInterpolationStrategyMessage | SetInterpolationStrategyParamsMessage - | SetStretchParametersMessage; + | SetStretchParametersMessage + | DisposeMessage; /** * Data returned by {@link SoundTouchProcessorBase.processCore} after a render block.