Skip to content

Commit e3341fc

Browse files
committed
feat(core): Add StretchPipe interface and stretchFactory option
Introduces StretchPipe as a structural interface for the time-stretch stage, implemented by the existing Stretch class. SoundTouchOptions gains a stretchFactory callback so callers can substitute a custom stage (e.g. a phase vocoder) without subclassing. Also fixes stale @cxing/ scopes and missing packages in nx.json release.projects.
1 parent 30f621a commit e3341fc

8 files changed

Lines changed: 257 additions & 19 deletions

File tree

nx.json

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -98,8 +98,11 @@
9898
"projects": [
9999
"@soundtouchjs/core",
100100
"@soundtouchjs/audio-worklet",
101-
"@cxing/interpolation-strategy-lanczos",
102-
"@cxing/interpolation-strategy-linear"
101+
"@soundtouchjs/interpolation-strategy-lanczos",
102+
"@soundtouchjs/interpolation-strategy-linear",
103+
"@soundtouchjs/interpolation-strategy-hann",
104+
"@soundtouchjs/interpolation-strategy-blackman",
105+
"@soundtouchjs/interpolation-strategy-kaiser"
103106
],
104107
"changelog": {
105108
"automaticFromRef": false,

packages/core/README.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,9 @@ st.outputBuffer.extract(outputBuffer, 0, 4096);
107107
| `resolveInterpolationStrategyRuntime` | Resolves runtime kernel + normalized params for an option |
108108
| `setActiveInterpolationStrategy` | Changes process-wide default interpolation strategy id |
109109
| `StretchParameters` | Type for WSOLA timing parameters passed to `setStretchParameters` |
110+
| `StretchPipe` | Interface for a WSOLA-compatible stretch stage (implement to replace it) |
111+
| `StretchFactory` | Factory function type for creating a custom `StretchPipe` |
112+
| `StretchFactoryOptions` | Options passed from `SoundTouch` to a `StretchFactory` |
110113

111114
#### WSOLA timing parameters
112115

@@ -130,6 +133,22 @@ st.setStretchParameters({ sequenceMs: 0 }); // back to auto
130133

131134
`Stretch` also exposes individual `overlapMs` (getter/setter) and `quickSeek` (getter/setter) properties.
132135

136+
#### Custom stretch stage via `stretchFactory`
137+
138+
`SoundTouchOptions.stretchFactory` lets you replace the built-in WSOLA `Stretch` stage with any `StretchPipe`-compatible implementation:
139+
140+
```ts
141+
import { SoundTouch } from '@soundtouchjs/core';
142+
import type { StretchFactory } from '@soundtouchjs/core';
143+
144+
const myFactory: StretchFactory = (sampleRate, opts) =>
145+
new MyCustomStretch(sampleRate, opts);
146+
147+
const st = new SoundTouch({ stretchFactory: myFactory });
148+
```
149+
150+
The factory receives the sample rate and a `StretchFactoryOptions` object containing `sampleBufferFactory` and `sampleBufferType`. `SoundTouch` calls `setParameters` on the returned instance after construction.
151+
133152
## Constructor API (breaking)
134153

135154
`@soundtouchjs/core` constructors now use named options objects instead of positional arguments.

packages/core/src/SoundTouch.spec.ts

Lines changed: 54 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
1-
import { describe, it, expect } from 'vitest';
1+
import { describe, it, expect, vi } from 'vitest';
22
import CircularSampleBuffer from './CircularSampleBuffer.js';
33
import SoundTouch from './SoundTouch.js';
44
import FifoSampleBuffer from './FifoSampleBuffer.js';
55
import RateTransposer from './RateTransposer.js';
66
import Stretch from './Stretch.js';
77
import type { SampleBuffer } from './SampleBuffer.js';
8+
import type { StretchFactory, StretchPipe } from './StretchPipe.js';
89

910
class TestSampleBuffer implements SampleBuffer {
1011
private samples: Float32Array;
@@ -209,13 +210,13 @@ describe('SoundTouch', () => {
209210
it('delegates to stretch and updates overlapMs', () => {
210211
const st = new SoundTouch({});
211212
st.setStretchParameters({ overlapMs: 14 });
212-
expect(st.stretch.overlapMs).toBe(14);
213+
expect((st.stretch as Stretch).overlapMs).toBe(14);
213214
});
214215

215216
it('delegates quickSeek flag to stretch', () => {
216217
const st = new SoundTouch({});
217218
st.setStretchParameters({ quickSeek: false });
218-
expect(st.stretch.quickSeek).toBe(false);
219+
expect((st.stretch as Stretch).quickSeek).toBe(false);
219220
});
220221
});
221222

@@ -339,4 +340,54 @@ describe('SoundTouch', () => {
339340
expect(st.outputBuffer.frameCount).toBeGreaterThanOrEqual(0);
340341
});
341342
});
343+
344+
describe('stretchFactory option', () => {
345+
it('uses the provided factory instead of constructing the default Stretch', () => {
346+
const mockStretch: StretchPipe = {
347+
inputBuffer: null,
348+
outputBuffer: null,
349+
tempo: 1,
350+
sampleReq: 128,
351+
clear: vi.fn(),
352+
clearMidBuffer: vi.fn(),
353+
process: vi.fn(),
354+
setParameters: vi.fn(),
355+
setStretchParameters: vi.fn(),
356+
clone: vi.fn().mockReturnThis(),
357+
};
358+
359+
const factory: StretchFactory = vi.fn().mockReturnValue(mockStretch);
360+
361+
const st = new SoundTouch({
362+
sampleRate: 44100,
363+
stretchFactory: factory,
364+
});
365+
366+
expect(factory).toHaveBeenCalledWith(
367+
44100,
368+
expect.objectContaining({ sampleBufferType: 'circular' }),
369+
);
370+
expect(st.stretch).toBe(mockStretch);
371+
});
372+
373+
it('calls setParameters on the factory-produced stretch after creation', () => {
374+
const mockStretch: StretchPipe = {
375+
inputBuffer: null,
376+
outputBuffer: null,
377+
tempo: 1,
378+
sampleReq: 128,
379+
clear: vi.fn(),
380+
clearMidBuffer: vi.fn(),
381+
process: vi.fn(),
382+
setParameters: vi.fn(),
383+
setStretchParameters: vi.fn(),
384+
clone: vi.fn().mockReturnThis(),
385+
};
386+
387+
const factory: StretchFactory = vi.fn().mockReturnValue(mockStretch);
388+
new SoundTouch({ sampleRate: 48000, stretchFactory: factory });
389+
390+
expect(mockStretch.setParameters).toHaveBeenCalledWith(48000, 0, 0, 0);
391+
});
392+
});
342393
});

packages/core/src/SoundTouch.ts

Lines changed: 31 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import {
3232
default as Stretch,
3333
} from './Stretch.js';
3434
import type { StretchParameters } from './Stretch.js';
35+
import type { StretchFactory, StretchPipe } from './StretchPipe.js';
3536
import CircularSampleBuffer from './CircularSampleBuffer.js';
3637
import FifoSampleBuffer from './FifoSampleBuffer.js';
3738
import {
@@ -80,6 +81,20 @@ export interface SoundTouchOptions {
8081
* @defaultValue 'linear'
8182
*/
8283
interpolationStrategy?: RateTransposerInterpolationStrategy;
84+
85+
/**
86+
* Optional factory for creating a custom time-stretch stage.
87+
*
88+
* @remarks
89+
* When provided, `SoundTouch` calls this function instead of constructing
90+
* the default WSOLA `Stretch` instance. Use this to substitute a phase
91+
* vocoder or any other `StretchPipe`-compatible implementation.
92+
*
93+
* @example
94+
* import { createPhaseVocoderFactory } from '@soundtouchjs/stretch-phase-vocoder';
95+
* const st = new SoundTouch({ stretchFactory: createPhaseVocoderFactory() });
96+
*/
97+
stretchFactory?: StretchFactory;
8398
}
8499

85100
/**
@@ -93,7 +108,7 @@ export interface SoundTouchOptions {
93108
*/
94109
export default class SoundTouch {
95110
transposer: RateTransposer;
96-
stretch: Stretch;
111+
stretch: StretchPipe;
97112

98113
private _sampleRate: number;
99114

@@ -132,16 +147,22 @@ export default class SoundTouch {
132147
interpolationStrategy: options.interpolationStrategy,
133148
});
134149
this._interpolationStrategy = this.transposer.strategy;
135-
this.stretch = new Stretch({
136-
createBuffers: false,
137-
inputBufferAdapterFactory:
138-
this._sampleBufferType === 'circular'
139-
? createCircularStretchInputBufferAdapter
140-
: createFifoStretchInputBufferAdapter,
141-
sampleBufferFactory: this._sampleBufferFactory,
142-
});
143-
144150
this._sampleRate = options.sampleRate ?? 44100;
151+
if (options.stretchFactory) {
152+
this.stretch = options.stretchFactory(this._sampleRate, {
153+
sampleBufferFactory: this._sampleBufferFactory,
154+
sampleBufferType: this._sampleBufferType,
155+
});
156+
} else {
157+
this.stretch = new Stretch({
158+
createBuffers: false,
159+
inputBufferAdapterFactory:
160+
this._sampleBufferType === 'circular'
161+
? createCircularStretchInputBufferAdapter
162+
: createFifoStretchInputBufferAdapter,
163+
sampleBufferFactory: this._sampleBufferFactory,
164+
});
165+
}
145166
this.stretch.setParameters(this._sampleRate, 0, 0, 0);
146167

147168
this._inputBuffer = this._sampleBufferFactory();

packages/core/src/Stretch.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import AbstractSamplePipe from './AbstractSamplePipe.js';
2424
import CircularSampleBuffer from './CircularSampleBuffer.js';
2525
import FifoSampleBuffer from './FifoSampleBuffer.js';
2626
import type { SampleBuffer } from './SampleBuffer.js';
27+
import type { StretchPipe } from './StretchPipe.js';
2728

2829
/**
2930
* Read adapter used by `Stretch` so input access is decoupled from concrete
@@ -402,10 +403,10 @@ const QUICK_SEEK_MIN_VALID_CANDIDATES = 8;
402403
* Time-stretch processor for tempo adjustment without affecting pitch.
403404
* Used internally by SoundTouch for time-stretching audio.
404405
*/
405-
export default class Stretch extends AbstractSamplePipe<
406-
SampleBuffer,
407-
SampleBuffer
408-
> {
406+
export default class Stretch
407+
extends AbstractSamplePipe<SampleBuffer, SampleBuffer>
408+
implements StretchPipe
409+
{
409410
private readonly inputBufferAdapterFactory: StretchInputBufferAdapterFactory;
410411
private readonly sampleBufferFactory: () => SampleBuffer;
411412
private readonly inputBufferAdapter: StretchReadBufferAdapter;

packages/core/src/StretchPipe.ts

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
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 { SampleBuffer, SampleBufferType } from './SampleBuffer.js';
21+
import type { StretchParameters } from './Stretch.js';
22+
23+
/**
24+
* Structural interface for a WSOLA time-stretch processing stage.
25+
*
26+
* @remarks
27+
* Implemented by the built-in `Stretch` class. Expose this interface as the type
28+
* for custom stretch implementations supplied via `SoundTouchOptions.stretchFactory`
29+
* so callers can swap in a phase vocoder or any other algorithm without subclassing.
30+
*
31+
* @example
32+
* const myFactory: StretchFactory = (sampleRate, opts) => new PhaseVocoderStretch(sampleRate, opts);
33+
* const st = new SoundTouch({ stretchFactory: myFactory });
34+
*/
35+
export interface StretchPipe {
36+
/** Input buffer that feeds audio frames into the stretch stage. */
37+
inputBuffer: SampleBuffer | null;
38+
/** Output buffer that the stretch stage writes processed frames into. */
39+
outputBuffer: SampleBuffer | null;
40+
/** Tempo multiplier (1.0 = original speed). */
41+
tempo: number;
42+
/** Minimum number of input frames required before the stage can produce output. */
43+
readonly sampleReq: number;
44+
45+
/**
46+
* Resets all internal state, including the mid-buffer.
47+
*/
48+
clear(): void;
49+
50+
/**
51+
* Resets only the overlap mid-buffer without touching the sample buffers.
52+
*/
53+
clearMidBuffer(): void;
54+
55+
/**
56+
* Runs one processing step, consuming frames from `inputBuffer` and writing to `outputBuffer`.
57+
*/
58+
process(): void;
59+
60+
/**
61+
* Configures the WSOLA timing parameters.
62+
*
63+
* @param sampleRate Processing sample rate in Hz.
64+
* @param sequenceMs Sequence window length in ms; `0` = auto.
65+
* @param seekWindowMs Seek window length in ms; `0` = auto.
66+
* @param overlapMs Crossfade overlap length in ms.
67+
*/
68+
setParameters(
69+
sampleRate: number,
70+
sequenceMs: number,
71+
seekWindowMs: number,
72+
overlapMs: number,
73+
): void;
74+
75+
/**
76+
* Applies a partial set of WSOLA timing parameters without requiring all four values.
77+
*
78+
* @param params Partial timing parameters to update.
79+
*/
80+
setStretchParameters(params: StretchParameters): void;
81+
82+
/**
83+
* Creates an independent copy with the same configuration and state.
84+
* @returns A new `StretchPipe` instance cloned from this one.
85+
*/
86+
clone(): StretchPipe;
87+
}
88+
89+
/**
90+
* Options passed to a `StretchFactory` when `SoundTouch` creates its stretch stage.
91+
*/
92+
export interface StretchFactoryOptions {
93+
/** Factory for creating the sample buffers shared with the rest of the pipeline. */
94+
sampleBufferFactory: () => SampleBuffer;
95+
/** Buffer strategy identifier (passed through from `SoundTouchOptions`). */
96+
sampleBufferType: SampleBufferType;
97+
}
98+
99+
/**
100+
* Factory function that creates a custom stretch processing stage.
101+
*
102+
* @remarks
103+
* Pass this to `SoundTouchOptions.stretchFactory` to replace the built-in WSOLA `Stretch`
104+
* with a custom implementation (e.g. a phase vocoder).
105+
*
106+
* @param sampleRate Processing sample rate in Hz.
107+
* @param options Additional options supplied by `SoundTouch`.
108+
* @returns A `StretchPipe` instance ready to be wired into the processing chain.
109+
*/
110+
export type StretchFactory = (
111+
sampleRate: number,
112+
options: StretchFactoryOptions,
113+
) => StretchPipe;

packages/core/src/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,3 +69,8 @@ export type {
6969
RateTransposerInterpolationStrategyOption,
7070
} from './interpolationStrategyRegistry.js';
7171
export type { SoundTouchOptions } from './SoundTouch.js';
72+
export type {
73+
StretchFactory,
74+
StretchFactoryOptions,
75+
StretchPipe,
76+
} from './StretchPipe.js';

storybook/src/docs/SoundTouch.mdx

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,18 @@ new SoundTouch({
2828
sampleBufferType?: 'fifo' | 'circular',
2929
sampleBufferFactory?: SampleBufferFactory,
3030
interpolationStrategy?: RateTransposerInterpolationStrategy,
31+
stretchFactory?: StretchFactory,
3132
})
3233
```
3334

35+
| Option | Default | Description |
36+
|--------|---------|-------------|
37+
| `sampleRate` | `44100` | Processing sample rate in Hz |
38+
| `sampleBufferType` | `'circular'` | Buffer strategy for all chain buffers |
39+
| `sampleBufferFactory` || Custom factory; overrides `sampleBufferType` |
40+
| `interpolationStrategy` | `'lanczos'` | Rate transposer interpolation kernel |
41+
| `stretchFactory` || Factory for a custom time-stretch stage; when provided, the default WSOLA `Stretch` is not created |
42+
3443
## Public API
3544

3645
- virtualPitch (field) — current pitch multiplier; updated by the pitch setters
@@ -61,4 +70,20 @@ st.setStretchParameters({ overlapMs: 12, quickSeek: false });
6170

6271
Delegates to `Stretch.setStretchParameters`. See [Stretch](./stretch) for the full parameter reference.
6372

73+
## stretchFactory option
74+
75+
Replace the built-in WSOLA `Stretch` stage with any `StretchPipe`-compatible implementation:
76+
77+
```ts
78+
import { SoundTouch } from '@soundtouchjs/core';
79+
import type { StretchFactory } from '@soundtouchjs/core';
80+
81+
const myFactory: StretchFactory = (sampleRate, opts) =>
82+
new MyCustomStretch(sampleRate, opts);
83+
84+
const st = new SoundTouch({ stretchFactory: myFactory });
85+
```
86+
87+
The factory receives the sample rate and a `StretchFactoryOptions` object containing `sampleBufferFactory` and `sampleBufferType`. `SoundTouch` calls `setParameters(sampleRate, 0, 0, 0)` on the returned instance after construction.
88+
6489
> **Note:** `tempo` and `rate` are internal pipeline values derived from `virtualPitch` — they are not part of the public API. For browser playback, use [`SoundTouchNode`](../audio-worklet/sound-touch-node), which manages the pipeline internally.

0 commit comments

Comments
 (0)