Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions packages/audio-worklet/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
15 changes: 15 additions & 0 deletions packages/audio-worklet/src/SoundTouchNode.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const lastCtorOptions: { value: MockNodeOptions | undefined } = {
type MessageListener = (event: MessageEvent) => void;

class MockAudioWorkletNode {
disconnect = vi.fn();
parameters: Map<string, unknown>;
port: {
postMessage: ReturnType<typeof vi.fn>;
Expand Down Expand Up @@ -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<typeof vi.fn> };
disconnect: ReturnType<typeof vi.fn>;
};
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');
Expand Down
15 changes: 15 additions & 0 deletions packages/audio-worklet/src/SoundTouchNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
}
24 changes: 24 additions & 0 deletions packages/audio-worklet/src/processor.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down
1 change: 1 addition & 0 deletions packages/formant-correction-worklet/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const lastCtorOptions: { value: MockNodeOptions | undefined } = {
type MessageListener = (event: MessageEvent) => void;

class MockAudioWorkletNode {
disconnect = vi.fn();
parameters: Map<string, unknown>;
port: {
postMessage: ReturnType<typeof vi.fn>;
Expand Down Expand Up @@ -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<typeof vi.fn> };
disconnect: ReturnType<typeof vi.fn>;
};
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');
Expand Down
15 changes: 15 additions & 0 deletions packages/formant-correction-worklet/src/FormantCorrectionNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
}
1 change: 1 addition & 0 deletions packages/phase-vocoder-worklet/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
15 changes: 15 additions & 0 deletions packages/phase-vocoder-worklet/src/PhaseVocoderNode.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const lastCtorOptions: { value: MockNodeOptions | undefined } = {
type MessageListener = (event: MessageEvent) => void;

class MockAudioWorkletNode {
disconnect = vi.fn();
parameters: Map<string, unknown>;
port: {
postMessage: ReturnType<typeof vi.fn>;
Expand Down Expand Up @@ -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<typeof vi.fn> };
disconnect: ReturnType<typeof vi.fn>;
};
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');
Expand Down
15 changes: 15 additions & 0 deletions packages/phase-vocoder-worklet/src/PhaseVocoderNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
}
6 changes: 5 additions & 1 deletion packages/worklet-base/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
20 changes: 19 additions & 1 deletion packages/worklet-base/src/SoundTouchProcessorBase.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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());
Expand All @@ -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)', () => {
Expand Down
18 changes: 16 additions & 2 deletions packages/worklet-base/src/SoundTouchProcessorBase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ export abstract class SoundTouchProcessorBase extends AudioWorkletProcessor {
private _pendingInterpolationStrategyParams: Partial<InterpolationStrategyParams> | null =
null;
private _pendingStretchParameters: StretchParameters | null = null;
private _disposed = false;

/** Label used in console messages (e.g. `'[SoundTouchProcessor]'`). */
protected readonly processorLabel: string;
Expand Down Expand Up @@ -96,6 +97,10 @@ export abstract class SoundTouchProcessorBase extends AudioWorkletProcessor {
if (port !== undefined) {
port.onmessage = (event: MessageEvent<ProcessorMessage>) => {
const message = event.data;
if (message.type === 'dispose') {
this._disposed = true;
return;
}
if (message.type === 'set-interpolation-strategy') {
this._pendingInterpolationStrategy = message.strategy;
return;
Expand Down Expand Up @@ -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<string, Float32Array>,
): boolean {
if (this._disposed) {
return false;
}
this.applyPendingRuntimeUpdates();
const result = this.processCore(inputs, outputs, parameters);
if (result !== null) {
Expand Down
1 change: 1 addition & 0 deletions packages/worklet-base/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export { SoundTouchProcessorBase } from './SoundTouchProcessorBase.js';
export { STANDARD_PARAMETER_DESCRIPTORS } from './types.js';
export type {
DisposeMessage,
ParameterDescriptor,
ProcessCoreResult,
ProcessorMessage,
Expand Down
11 changes: 10 additions & 1 deletion packages/worklet-base/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down