Skip to content

Commit a14eb10

Browse files
committed
feat(audio-worklet): Add processOffline() for OfflineAudioContext rendering
Add processOffline() helper that renders an AudioBuffer through SoundTouch in an OfflineAudioContext without requiring a live audio device. Accepts pitch, pitchSemitones, playbackRate, interpolationStrategy, stretchParameters, and sampleBufferType options. Output length is estimated as ceil(input.length / playbackRate).
1 parent c8196a8 commit a14eb10

7 files changed

Lines changed: 442 additions & 1 deletion

File tree

packages/audio-worklet/README.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,24 @@ new SoundTouchNode({ context: audioCtx, sampleBufferType: 'fifo' });
265265
- **Processor thread**: `SoundTouchProcessor` extends `AudioWorkletProcessor`, runs on the audio rendering thread. It interleaves stereo input, feeds it through the `SoundTouch` processing pipe, and deinterleaves the output. The `@soundtouchjs/core` library is bundled directly into the processor file so there are no import dependencies at runtime.
266266
- **Main thread**: `SoundTouchNode` extends `AudioWorkletNode`, providing typed `AudioParam` accessors for `pitch`, `pitchSemitones`, and `playbackRate`. A static `register()` method handles `audioWorklet.addModule()`. When `playbackRate` is set to the same value as the source node's `playbackRate`, the processor automatically divides the desired pitch by that value, so developers never need to manually compensate for rate-induced pitch shift.
267267

268+
## Offline processing
269+
270+
Use `processOffline()` to render an entire `AudioBuffer` through SoundTouch without a live audio device:
271+
272+
```ts
273+
import { processOffline } from '@soundtouchjs/audio-worklet';
274+
275+
const processed = await processOffline({
276+
input: audioBuffer,
277+
processorUrl: '/soundtouch-processor.js',
278+
pitchSemitones: -3,
279+
playbackRate: 1.2,
280+
stretchParameters: { overlapMs: 12 },
281+
});
282+
```
283+
284+
The output `AudioBuffer` has the same channel count and sample rate as the input. Output length is estimated as `ceil(input.length / playbackRate)`.
285+
268286
## Mono input and output
269287

270288
The processor supports both mono input and mono output without extra configuration.

packages/audio-worklet/src/index.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,6 @@ describe('audio-worklet public exports', () => {
55
it('exposes the expected runtime symbols', () => {
66
const runtimeKeys = Object.keys(AudioWorkletPackage).sort();
77

8-
expect(runtimeKeys).toEqual(['PROCESSOR_NAME', 'SoundTouchNode']);
8+
expect(runtimeKeys).toEqual(['PROCESSOR_NAME', 'SoundTouchNode', 'processOffline']);
99
});
1010
});

packages/audio-worklet/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,3 +32,5 @@ export type {
3232
StretchParameters,
3333
} from './SoundTouchNode.js';
3434
export { PROCESSOR_NAME } from './constants.js';
35+
export { processOffline } from './processOffline.js';
36+
export type { ProcessOfflineOptions } from './processOffline.js';
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
import { beforeEach, describe, expect, it, vi } from 'vitest';
2+
3+
// ─── Minimal stubs ──────────────────────────────────────────────────────────
4+
5+
const addModule = vi.fn().mockResolvedValue(undefined);
6+
7+
const mockPitchParam = { value: 1.0 };
8+
const mockSemitonesParam = { value: 0 };
9+
const mockPlaybackRateParam = { value: 1.0 };
10+
const mockSetStretchParameters = vi.fn();
11+
const mockConnect = vi.fn();
12+
13+
const mockSourcePlaybackRate = { value: 1.0 };
14+
const mockSourceConnect = vi.fn();
15+
const mockSourceStart = vi.fn();
16+
17+
const mockCreateBufferSource = vi.fn(() => ({
18+
playbackRate: mockSourcePlaybackRate,
19+
buffer: null as AudioBuffer | null,
20+
connect: mockSourceConnect,
21+
start: mockSourceStart,
22+
}));
23+
24+
const mockRenderedBuffer = {} as AudioBuffer;
25+
const mockStartRendering = vi.fn().mockResolvedValue(mockRenderedBuffer);
26+
27+
const mockDestination = {} as AudioDestinationNode;
28+
29+
class MockOfflineAudioContext {
30+
numberOfChannels: number;
31+
length: number;
32+
sampleRate: number;
33+
audioWorklet = { addModule };
34+
destination = mockDestination;
35+
startRendering = mockStartRendering;
36+
createBufferSource = mockCreateBufferSource;
37+
38+
constructor(numberOfChannels: number, length: number, sampleRate: number) {
39+
this.numberOfChannels = numberOfChannels;
40+
this.length = length;
41+
this.sampleRate = sampleRate;
42+
}
43+
}
44+
45+
class MockAudioWorkletNode {
46+
parameters: Map<string, unknown>;
47+
port = { postMessage: vi.fn() };
48+
connect = mockConnect;
49+
50+
constructor() {
51+
this.parameters = new Map([
52+
['pitch', mockPitchParam],
53+
['pitchSemitones', mockSemitonesParam],
54+
['playbackRate', mockPlaybackRateParam],
55+
]);
56+
}
57+
}
58+
59+
// ─── Setup ──────────────────────────────────────────────────────────────────
60+
61+
beforeEach(() => {
62+
vi.resetModules();
63+
addModule.mockClear();
64+
mockSetStretchParameters.mockClear();
65+
mockConnect.mockClear();
66+
mockSourceConnect.mockClear();
67+
mockSourceStart.mockClear();
68+
mockStartRendering.mockResolvedValue(mockRenderedBuffer);
69+
mockPitchParam.value = 1.0;
70+
mockSemitonesParam.value = 0;
71+
mockPlaybackRateParam.value = 1.0;
72+
mockSourcePlaybackRate.value = 1.0;
73+
74+
vi.stubGlobal('OfflineAudioContext', MockOfflineAudioContext);
75+
vi.stubGlobal('AudioWorkletNode', MockAudioWorkletNode);
76+
});
77+
78+
describe('processOffline', () => {
79+
function makeInput(length = 4410, channels = 2, sampleRate = 44100): AudioBuffer {
80+
return { length, numberOfChannels: channels, sampleRate } as AudioBuffer;
81+
}
82+
83+
it('registers the processor and returns the rendered buffer', async () => {
84+
const { processOffline } = await import('./processOffline.js');
85+
const input = makeInput();
86+
const result = await processOffline({
87+
input,
88+
processorUrl: '/proc.js',
89+
});
90+
91+
expect(addModule).toHaveBeenCalledWith('/proc.js');
92+
expect(mockStartRendering).toHaveBeenCalled();
93+
expect(result).toBe(mockRenderedBuffer);
94+
});
95+
96+
it('scales output length by 1/playbackRate', async () => {
97+
const { processOffline } = await import('./processOffline.js');
98+
const input = makeInput(44100);
99+
let capturedLength = 0;
100+
vi.stubGlobal(
101+
'OfflineAudioContext',
102+
class extends MockOfflineAudioContext {
103+
constructor(ch: number, len: number, sr: number) {
104+
super(ch, len, sr);
105+
capturedLength = len;
106+
}
107+
},
108+
);
109+
110+
await processOffline({ input, processorUrl: '/proc.js', playbackRate: 2.0 });
111+
expect(capturedLength).toBe(Math.ceil(44100 / 2.0));
112+
});
113+
114+
it('sets pitch, pitchSemitones, and playbackRate AudioParams', async () => {
115+
const { processOffline } = await import('./processOffline.js');
116+
const input = makeInput();
117+
await processOffline({
118+
input,
119+
processorUrl: '/proc.js',
120+
pitch: 1.2,
121+
pitchSemitones: -3,
122+
playbackRate: 1.5,
123+
});
124+
125+
expect(mockPitchParam.value).toBe(1.2);
126+
expect(mockSemitonesParam.value).toBe(-3);
127+
expect(mockPlaybackRateParam.value).toBe(1.5);
128+
expect(mockSourcePlaybackRate.value).toBe(1.5);
129+
});
130+
131+
it('calls setStretchParameters when stretchParameters is provided', async () => {
132+
// Patch SoundTouchNode to track setStretchParameters calls
133+
vi.stubGlobal('AudioWorkletNode', class extends MockAudioWorkletNode {});
134+
const mod = await import('./SoundTouchNode.js');
135+
const spy = vi
136+
.spyOn(mod.SoundTouchNode.prototype as unknown as { setStretchParameters: (p: unknown) => void }, 'setStretchParameters')
137+
.mockImplementation(() => {});
138+
139+
const { processOffline } = await import('./processOffline.js');
140+
const input = makeInput();
141+
await processOffline({
142+
input,
143+
processorUrl: '/proc.js',
144+
stretchParameters: { overlapMs: 12 },
145+
});
146+
147+
expect(spy).toHaveBeenCalledWith({ overlapMs: 12 });
148+
spy.mockRestore();
149+
});
150+
151+
it('skips setStretchParameters when not provided', async () => {
152+
const mod = await import('./SoundTouchNode.js');
153+
const spy = vi
154+
.spyOn(mod.SoundTouchNode.prototype as unknown as { setStretchParameters: (p: unknown) => void }, 'setStretchParameters')
155+
.mockImplementation(() => {});
156+
157+
const { processOffline } = await import('./processOffline.js');
158+
await processOffline({ input: makeInput(), processorUrl: '/proc.js' });
159+
160+
expect(spy).not.toHaveBeenCalled();
161+
spy.mockRestore();
162+
});
163+
164+
it('connects the graph: source → stNode → destination', async () => {
165+
const connectCalls: unknown[] = [];
166+
vi.stubGlobal(
167+
'AudioWorkletNode',
168+
class extends MockAudioWorkletNode {
169+
connect(dest: unknown) {
170+
connectCalls.push({ from: 'stNode', to: dest });
171+
}
172+
},
173+
);
174+
mockSourceConnect.mockImplementation((dest: unknown) => {
175+
connectCalls.push({ from: 'source', to: dest });
176+
});
177+
178+
const { processOffline } = await import('./processOffline.js');
179+
await processOffline({ input: makeInput(), processorUrl: '/proc.js' });
180+
181+
expect(connectCalls.some((c) => (c as { from: string }).from === 'source')).toBe(true);
182+
expect(mockSourceStart).toHaveBeenCalledWith(0);
183+
});
184+
});
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
/*
2+
* SoundTouch JS audio processing library
3+
* Copyright (c) Steve 'Cutter' Blades
4+
*
5+
* This library is free software; you can redistribute it and/or
6+
* modify it under the terms of the GNU Lesser General Public
7+
* License as published by the Free Software Foundation; either
8+
* version 3 of the License.
9+
*
10+
* This library is distributed in the hope that it will be useful,
11+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
13+
* Lesser General Public License for more details.
14+
*
15+
* You should have received a copy of the GNU Lesser General Public
16+
* License along with this library; if not, write to the Free Software
17+
* Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
18+
*/
19+
20+
import type {
21+
RateTransposerInterpolationStrategy,
22+
SampleBufferType,
23+
StretchParameters,
24+
} from '@soundtouchjs/core';
25+
import { SoundTouchNode } from './SoundTouchNode.js';
26+
27+
/**
28+
* Options for `processOffline`.
29+
*
30+
* @remarks
31+
* All audio transform parameters are optional and default to their neutral values
32+
* (`pitch: 1.0`, `pitchSemitones: 0`, `playbackRate: 1.0`).
33+
*/
34+
export interface ProcessOfflineOptions {
35+
/**
36+
* The source audio buffer to process.
37+
*/
38+
input: AudioBuffer;
39+
40+
/**
41+
* URL or path to the SoundTouch processor script, passed to `SoundTouchNode.register`.
42+
*/
43+
processorUrl: string | URL;
44+
45+
/**
46+
* Pitch multiplier (1.0 = original pitch).
47+
* @defaultValue 1.0
48+
*/
49+
pitch?: number;
50+
51+
/**
52+
* Pitch shift in semitones, combined with `pitch`.
53+
* @defaultValue 0
54+
*/
55+
pitchSemitones?: number;
56+
57+
/**
58+
* Playback rate multiplier. Output length is scaled by `1 / playbackRate`.
59+
* @defaultValue 1.0
60+
*/
61+
playbackRate?: number;
62+
63+
/**
64+
* Interpolation strategy to use in the rate transposer.
65+
*/
66+
interpolationStrategy?: RateTransposerInterpolationStrategy;
67+
68+
/**
69+
* WSOLA timing parameters to apply to the stretch stage.
70+
*/
71+
stretchParameters?: StretchParameters;
72+
73+
/**
74+
* Internal sample buffer strategy.
75+
*/
76+
sampleBufferType?: SampleBufferType;
77+
}
78+
79+
/**
80+
* Renders audio through SoundTouch processing in an `OfflineAudioContext`.
81+
*
82+
* @remarks
83+
* Creates an `OfflineAudioContext`, registers the processor module, builds the
84+
* audio graph, applies all transform parameters, and returns the rendered
85+
* `AudioBuffer`. The output length is estimated as
86+
* `ceil(input.length / playbackRate)` to account for tempo changes.
87+
*
88+
* @param options Processing options including the input buffer and processor URL.
89+
* @returns A Promise that resolves to the processed `AudioBuffer`.
90+
*
91+
* @example
92+
* ```ts
93+
* import { processOffline } from '@soundtouchjs/audio-worklet';
94+
*
95+
* const processed = await processOffline({
96+
* input: audioBuffer,
97+
* processorUrl: '/soundtouch-processor.js',
98+
* pitchSemitones: -3,
99+
* playbackRate: 1.2,
100+
* });
101+
* ```
102+
*/
103+
export async function processOffline(
104+
options: ProcessOfflineOptions,
105+
): Promise<AudioBuffer> {
106+
const {
107+
input,
108+
processorUrl,
109+
pitch = 1.0,
110+
pitchSemitones = 0,
111+
playbackRate = 1.0,
112+
interpolationStrategy,
113+
stretchParameters,
114+
sampleBufferType,
115+
} = options;
116+
117+
const outputLength = Math.ceil(input.length / playbackRate);
118+
119+
const offlineCtx = new OfflineAudioContext(
120+
input.numberOfChannels,
121+
outputLength,
122+
input.sampleRate,
123+
);
124+
125+
await SoundTouchNode.register(offlineCtx, processorUrl);
126+
127+
const stNode = new SoundTouchNode({
128+
context: offlineCtx,
129+
interpolationStrategy,
130+
sampleBufferType,
131+
});
132+
133+
stNode.pitch.value = pitch;
134+
stNode.pitchSemitones.value = pitchSemitones;
135+
stNode.playbackRate.value = playbackRate;
136+
137+
if (stretchParameters) {
138+
stNode.setStretchParameters(stretchParameters);
139+
}
140+
141+
stNode.connect(offlineCtx.destination);
142+
143+
const source = offlineCtx.createBufferSource();
144+
source.buffer = input;
145+
source.playbackRate.value = playbackRate;
146+
source.connect(stNode);
147+
source.start(0);
148+
149+
return offlineCtx.startRendering();
150+
}

0 commit comments

Comments
 (0)