Skip to content

Commit c8196a8

Browse files
committed
feat(core,audio-worklet): Expose WSOLA timing parameters
Add StretchParameters interface (sequenceMs, seekWindowMs, overlapMs, quickSeek) and setStretchParameters() method to Stretch and SoundTouch for fine-grained control of the WSOLA time-stretch algorithm at runtime. Add public overlapMs getter/setter and quickSeek getter to Stretch, making the previously-hidden timing internals accessible without replacing all parameters at once. Add SetStretchParametersMessage to the processor message union so SoundTouchNode.setStretchParameters() can queue updates to the render thread.
1 parent 94d114b commit c8196a8

15 files changed

Lines changed: 446 additions & 6 deletions

File tree

packages/audio-worklet/README.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,24 @@ stNode.setInterpolationStrategyParams({ edgeHoldFrames: 4 });
141141

142142
These updates are applied by the processor at render-block boundaries for stable transitions.
143143

144+
### WSOLA timing parameters
145+
146+
Use `setStretchParameters()` to tune the time-stretch algorithm. Updates are queued and applied at the next render-block boundary.
147+
148+
```ts
149+
stNode.setStretchParameters({ overlapMs: 12 }); // overlap only
150+
stNode.setStretchParameters({ quickSeek: false }); // exhaustive search
151+
stNode.setStretchParameters({ sequenceMs: 80, seekWindowMs: 20 }); // manual windows
152+
stNode.setStretchParameters({ sequenceMs: 0 }); // back to auto
153+
```
154+
155+
| Param | Default | Description |
156+
|-------|---------|-------------|
157+
| `sequenceMs` | auto (50–125 ms) | Processing window in ms; `0` = auto |
158+
| `seekWindowMs` | auto (15–25 ms) | Seek window in ms; `0` = auto |
159+
| `overlapMs` | 8 ms | Crossfade overlap in ms |
160+
| `quickSeek` | `true` | Fast seek; `false` = exhaustive |
161+
144162
### Full example — AudioBuffer
145163

146164
```ts

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

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,22 @@ describe('SoundTouchNode', () => {
109109
});
110110
});
111111

112+
it('sends set-stretch-parameters message via MessagePort', async () => {
113+
const { SoundTouchNode } = await import('./index.js');
114+
const context = {} as BaseAudioContext;
115+
const node = new SoundTouchNode({ context });
116+
117+
node.setStretchParameters({ overlapMs: 12, quickSeek: false });
118+
119+
const port = (
120+
node as unknown as { port: { postMessage: ReturnType<typeof vi.fn> } }
121+
).port;
122+
expect(port.postMessage).toHaveBeenCalledWith({
123+
type: 'set-stretch-parameters',
124+
params: { overlapMs: 12, quickSeek: false },
125+
});
126+
});
127+
112128
describe('outputChannelCount option', () => {
113129
it('defaults to stereo (outputChannelCount [2]) when not specified', async () => {
114130
const { SoundTouchNode } = await import('./index.js');

packages/audio-worklet/src/SoundTouchNode.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,9 @@ import type {
2121
InterpolationStrategyParams,
2222
RateTransposerInterpolationStrategy,
2323
SampleBufferType,
24+
StretchParameters,
2425
} from '@soundtouchjs/core';
26+
export type { StretchParameters } from '@soundtouchjs/core';
2527
import { DEFAULT_SAMPLE_BUFFER_TYPE, PROCESSOR_NAME } from './constants.js';
2628

2729
/**
@@ -197,4 +199,24 @@ export class SoundTouchNode extends AudioWorkletNode {
197199
params,
198200
});
199201
}
202+
203+
/**
204+
* Applies a partial set of WSOLA timing parameters to the render-thread processor.
205+
*
206+
* @remarks
207+
* The update is queued and applied at the next render-block boundary. Only the
208+
* provided fields are updated; omitted fields remain unchanged. Pass `sequenceMs: 0`
209+
* or `seekWindowMs: 0` to switch that dimension back to auto-calculation.
210+
*
211+
* @param params Partial WSOLA timing parameters to apply.
212+
*
213+
* @example
214+
* stNode.setStretchParameters({ overlapMs: 12, quickSeek: false });
215+
*/
216+
setStretchParameters(params: StretchParameters): void {
217+
this.port.postMessage({
218+
type: 'set-stretch-parameters',
219+
params,
220+
});
221+
}
200222
}

packages/audio-worklet/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,5 +29,6 @@ export { SoundTouchNode } from './SoundTouchNode.js';
2929
export type {
3030
SoundTouchNodeConstructorOptions,
3131
SoundTouchNodeOptions,
32+
StretchParameters,
3233
} from './SoundTouchNode.js';
3334
export { PROCESSOR_NAME } from './constants.js';

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

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ const extract = vi.fn();
2121
const receive = vi.fn();
2222
const setInterpolationStrategy = vi.fn();
2323
const setInterpolationStrategyParams = vi.fn();
24+
const setStretchParameters = vi.fn();
2425
const soundTouchCtorArgs: unknown[] = [];
2526

2627
vi.mock('@soundtouchjs/core', () => {
@@ -49,6 +50,10 @@ vi.mock('@soundtouchjs/core', () => {
4950
setInterpolationStrategyParams(params);
5051
}
5152

53+
setStretchParameters(params: unknown): void {
54+
setStretchParameters(params);
55+
}
56+
5257
constructor(options?: unknown) {
5358
soundTouchCtorArgs.push(options);
5459
}
@@ -75,6 +80,7 @@ beforeEach(() => {
7580
receive.mockReset();
7681
setInterpolationStrategy.mockReset();
7782
setInterpolationStrategyParams.mockReset();
83+
setStretchParameters.mockReset();
7884
soundTouchCtorArgs.length = 0;
7985
outputFrameCount = 0;
8086

@@ -235,6 +241,40 @@ describe('processor', () => {
235241
expect(instance.process([[new Float32Array(2)]], [[]], params)).toBe(true);
236242
});
237243

244+
it('applies pending stretch parameter updates from message port', async () => {
245+
await import('./processor.js');
246+
const instance = new registeredCtor!({
247+
processorOptions: { sampleBufferType: 'circular' },
248+
}) as unknown as {
249+
port: { onmessage: ((event: { data: unknown }) => void) | null };
250+
process: RegisteredProcessorCtor['prototype']['process'];
251+
};
252+
253+
instance.port.onmessage?.({
254+
data: {
255+
type: 'set-stretch-parameters',
256+
params: { overlapMs: 12, quickSeek: false },
257+
},
258+
});
259+
260+
const inputLeft = new Float32Array([1, 2]);
261+
const outputLeft = new Float32Array(2);
262+
const outputRight = new Float32Array(2);
263+
outputFrameCount = 0;
264+
265+
const ok = instance.process([[inputLeft]], [[outputLeft, outputRight]], {
266+
pitch: new Float32Array([1]),
267+
pitchSemitones: new Float32Array([0]),
268+
playbackRate: new Float32Array([1]),
269+
});
270+
271+
expect(ok).toBe(true);
272+
expect(setStretchParameters).toHaveBeenCalledWith({
273+
overlapMs: 12,
274+
quickSeek: false,
275+
});
276+
});
277+
238278
it('handles mono input/output channel fallback and buffer resize path', async () => {
239279
await import('./processor.js');
240280

packages/audio-worklet/src/processor.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import type {
2323
InterpolationStrategyParams,
2424
RateTransposerInterpolationStrategy,
2525
SampleBufferType,
26+
StretchParameters,
2627
} from '@soundtouchjs/core';
2728

2829
const PROCESSOR_NAME = 'soundtouch-processor';
@@ -71,9 +72,15 @@ interface SetInterpolationStrategyParamsMessage {
7172
params: Partial<InterpolationStrategyParams>;
7273
}
7374

75+
interface SetStretchParametersMessage {
76+
type: 'set-stretch-parameters';
77+
params: StretchParameters;
78+
}
79+
7480
type ProcessorMessage =
7581
| SetInterpolationStrategyMessage
76-
| SetInterpolationStrategyParamsMessage;
82+
| SetInterpolationStrategyParamsMessage
83+
| SetStretchParametersMessage;
7784

7885
/**
7986
* Audio render-thread processor that applies SoundTouch transformations to stereo blocks.
@@ -114,6 +121,7 @@ class SoundTouchProcessor extends AudioWorkletProcessor {
114121
private _outputSamples: Float32Array;
115122
private pendingInterpolationStrategy: RateTransposerInterpolationStrategy | null;
116123
private pendingInterpolationStrategyParams: Partial<InterpolationStrategyParams> | null;
124+
private pendingStretchParameters: StretchParameters | null;
117125

118126
/**
119127
* @param options Worklet constructor options provided by the main thread.
@@ -151,6 +159,7 @@ class SoundTouchProcessor extends AudioWorkletProcessor {
151159
this._outputSamples = new Float32Array(128 * 2);
152160
this.pendingInterpolationStrategy = null;
153161
this.pendingInterpolationStrategyParams = null;
162+
this.pendingStretchParameters = null;
154163

155164
const port = this.port;
156165
if (port !== undefined) {
@@ -162,6 +171,10 @@ class SoundTouchProcessor extends AudioWorkletProcessor {
162171
}
163172
if (message.type === 'set-interpolation-strategy-params') {
164173
this.pendingInterpolationStrategyParams = message.params;
174+
return;
175+
}
176+
if (message.type === 'set-stretch-parameters') {
177+
this.pendingStretchParameters = message.params;
165178
}
166179
};
167180
}
@@ -194,6 +207,18 @@ class SoundTouchProcessor extends AudioWorkletProcessor {
194207
}
195208
this.pendingInterpolationStrategyParams = null;
196209
}
210+
211+
if (this.pendingStretchParameters !== null) {
212+
try {
213+
this._pipe.setStretchParameters(this.pendingStretchParameters);
214+
} catch (err) {
215+
// eslint-disable-next-line no-console
216+
console.info(
217+
'[SoundTouchProcessor] Failed to update stretch parameters.',
218+
);
219+
}
220+
this.pendingStretchParameters = null;
221+
}
197222
}
198223

199224
process(

packages/core/README.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,29 @@ st.outputBuffer.extract(outputBuffer, 0, 4096);
106106
| `registerInterpolationStrategy` | Registers a custom interpolation strategy id (or kernel) |
107107
| `resolveInterpolationStrategyRuntime` | Resolves runtime kernel + normalized params for an option |
108108
| `setActiveInterpolationStrategy` | Changes process-wide default interpolation strategy id |
109+
| `StretchParameters` | Type for WSOLA timing parameters passed to `setStretchParameters` |
110+
111+
#### WSOLA timing parameters
112+
113+
`SoundTouch` exposes `setStretchParameters()` to tune the time-stretch algorithm at runtime:
114+
115+
```ts
116+
const st = new SoundTouch({});
117+
118+
st.setStretchParameters({ overlapMs: 12 }); // crossfade overlap only
119+
st.setStretchParameters({ quickSeek: false }); // exhaustive correlation search
120+
st.setStretchParameters({ sequenceMs: 80, seekWindowMs: 20 }); // manual windows
121+
st.setStretchParameters({ sequenceMs: 0 }); // back to auto
122+
```
123+
124+
| Param | Default | Description |
125+
|-------|---------|-------------|
126+
| `sequenceMs` | auto (50–125 ms) | Processing window length; `0` = auto-calculate from tempo |
127+
| `seekWindowMs` | auto (15–25 ms) | Seek window length; `0` = auto-calculate |
128+
| `overlapMs` | 8 ms | Crossfade overlap length |
129+
| `quickSeek` | `true` | Fast multi-pass seek; `false` = exhaustive (better quality, slower) |
130+
131+
`Stretch` also exposes individual `overlapMs` (getter/setter) and `quickSeek` (getter/setter) properties.
109132

110133
## Constructor API (breaking)
111134

packages/core/src/SoundTouch.spec.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,20 @@ describe('SoundTouch', () => {
205205
});
206206
});
207207

208+
describe('setStretchParameters', () => {
209+
it('delegates to stretch and updates overlapMs', () => {
210+
const st = new SoundTouch({});
211+
st.setStretchParameters({ overlapMs: 14 });
212+
expect(st.stretch.overlapMs).toBe(14);
213+
});
214+
215+
it('delegates quickSeek flag to stretch', () => {
216+
const st = new SoundTouch({});
217+
st.setStretchParameters({ quickSeek: false });
218+
expect(st.stretch.quickSeek).toBe(false);
219+
});
220+
});
221+
208222
describe('runtime interpolation strategy controls', () => {
209223
it('switches interpolation strategy through SoundTouch API', () => {
210224
const st = new SoundTouch({ interpolationStrategy: 'lanczos' });

packages/core/src/SoundTouch.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import {
3131
createFifoStretchInputBufferAdapter,
3232
default as Stretch,
3333
} from './Stretch.js';
34+
import type { StretchParameters } from './Stretch.js';
3435
import CircularSampleBuffer from './CircularSampleBuffer.js';
3536
import FifoSampleBuffer from './FifoSampleBuffer.js';
3637
import {
@@ -218,6 +219,23 @@ export default class SoundTouch {
218219
this.transposer.setInterpolationStrategyParams(params);
219220
}
220221

222+
/**
223+
* Applies a partial set of WSOLA timing parameters to the stretch stage.
224+
*
225+
* @remarks
226+
* Delegates directly to {@link Stretch.setStretchParameters}. Only the provided
227+
* fields are updated; omitted fields remain unchanged. Pass `sequenceMs: 0` or
228+
* `seekWindowMs: 0` to switch that dimension back to auto-calculation.
229+
*
230+
* @param params Partial set of WSOLA timing parameters to apply.
231+
*
232+
* @example
233+
* st.setStretchParameters({ overlapMs: 12, quickSeek: false });
234+
*/
235+
setStretchParameters(params: StretchParameters): void {
236+
this.stretch.setStretchParameters(params);
237+
}
238+
221239
/**
222240
* Sets the pitch multiplier and recomputes the derived pipeline rate and tempo.
223241
*

0 commit comments

Comments
 (0)