Skip to content
Merged
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
5 changes: 5 additions & 0 deletions MEMORY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
## 2026-07-07, Stretch constructor sample-rate option

**What was decided:** Add `sampleRate` to `StretchConstructorOptions`, pass it through `SoundTouch` when constructing the built-in `Stretch`, and keep `setParameters(sampleRate, ...)` as the runtime/update path.
**Why:** `Stretch` does not own or inspect an audio source directly, so the correct sample rate must be injected by the caller or owning pipeline instead of assuming 44100 at construction time.
**What was rejected:** Teaching `Stretch` to infer sample rate from buffers or source abstractions it does not receive, and leaving the constructor fixed at an implicit 44100 default.
13 changes: 12 additions & 1 deletion packages/core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,17 @@ st.setStretchParameters({ sequenceMs: 0 }); // back to auto

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

If you use `Stretch` directly, pass the source sample rate in the constructor so its initial overlap and seek windows match the material you are processing:

```ts
import { Stretch } from '@soundtouchjs/core';

const stretch = new Stretch({
createBuffers: true,
sampleRate: audioBuffer.sampleRate,
});
```

#### Custom stretch stage via `stretchFactory`

`SoundTouchOptions.stretchFactory` lets you replace the built-in WSOLA `Stretch` stage with any `StretchPipe`-compatible implementation:
Expand All @@ -158,7 +169,7 @@ const myFactory: StretchFactory = (sampleRate, opts) =>
const st = new SoundTouch({ stretchFactory: myFactory });
```

The factory receives the sample rate and a `StretchFactoryOptions` object containing `sampleBufferFactory` and `sampleBufferType`. `SoundTouch` calls `setParameters` on the returned instance after construction.
The factory receives the sample rate and a `StretchFactoryOptions` object containing `sampleBufferFactory` and `sampleBufferType`. `SoundTouch` calls `setParameters` on the returned instance after construction, and the built-in `Stretch` now also receives that sample rate in its constructor.

## Constructor API (breaking)

Expand Down
6 changes: 6 additions & 0 deletions packages/core/src/SoundTouch.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,12 @@ describe('SoundTouch', () => {
);
});

it('passes the explicit sample rate into the default Stretch constructor', () => {
const st = new SoundTouch({ sampleRate: 48000 });

expect((st.stretch as Stretch).overlapLength).toBe(384);
});

it('accepts interpolationStrategy option', () => {
const st = new SoundTouch({ interpolationStrategy: 'lanczos' });
expect(st.transposer).toBeInstanceOf(RateTransposer);
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/SoundTouch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@ export default class SoundTouch {
});
} else {
this.stretch = new Stretch({
sampleRate: this._sampleRate,
createBuffers: false,
inputBufferAdapterFactory:
this._sampleBufferType === 'circular'
Expand Down
8 changes: 8 additions & 0 deletions packages/core/src/Stretch.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,14 @@ describe('Stretch', () => {
expect(s.tempo).toBe(1);
});

it('accepts an explicit sample rate', () => {
const defaultRate = new Stretch({ createBuffers: true });
const highRate = new Stretch({ createBuffers: true, sampleRate: 48000 });

expect(highRate.overlapLength).toBeGreaterThan(defaultRate.overlapLength);
expect(highRate.sampleReq).toBeGreaterThan(defaultRate.sampleReq);
});

it('initializes overlap length from default parameters', () => {
const s = new Stretch({ createBuffers: true });
expect(s.overlapLength).toBeGreaterThan(0);
Expand Down
8 changes: 7 additions & 1 deletion packages/core/src/Stretch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,11 @@ export interface StretchParameters {
}

export interface StretchConstructorOptions {
/** Initial processing sample rate in Hz.
*
* @defaultValue 44100
*/
sampleRate?: number;
/** Whether to allocate internal input/output buffers. */
createBuffers?: boolean;
/** Factory for creating stretch input adapters. */
Expand Down Expand Up @@ -426,6 +431,7 @@ export default class Stretch
* @param options Constructor options.
*/
constructor({
sampleRate = 44100,
createBuffers = false,
inputBufferAdapterFactory = createFifoStretchInputBufferAdapter,
sampleBufferFactory = () => new FifoSampleBuffer(),
Expand All @@ -452,7 +458,7 @@ export default class Stretch

this._tempo = 1;
this.setParameters(
44100,
sampleRate,
DEFAULT_SEQUENCE_MS,
DEFAULT_SEEKWINDOW_MS,
DEFAULT_OVERLAP_MS,
Expand Down
5 changes: 4 additions & 1 deletion packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,10 @@ export { default as FifoSampleBuffer } from './FifoSampleBuffer.js';
export { default as RateTransposer } from './RateTransposer.js';
export { default as Stretch } from './Stretch.js';
export { default as SoundTouch } from './SoundTouch.js';
export type { StretchParameters } from './Stretch.js';
export type {
StretchConstructorOptions,
StretchParameters,
} from './Stretch.js';

import {
getActiveInterpolationStrategyId,
Expand Down
3 changes: 3 additions & 0 deletions packages/stretch-phase-vocoder/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,9 +121,12 @@ import { fft, ifft, makeHannWindow } from '@soundtouchjs/stretch-phase-vocoder';

| Option | Default | Description |
|--------|---------|-------------|
| `sampleRate` | ignored | Accepted for constructor/factory compatibility with `Stretch`; does not affect phase-vocoder processing |
| `fftSize` | 2048 | FFT frame size — larger gives better frequency resolution but higher latency (`fftSize` samples) |
| `overlapFactor` | 4 | Overlap factor — `4` = 75 % overlap (good quality); `8` = 87.5 % overlap (smoother, 2× cost) |

When created through `createPhaseVocoderFactory()`, the factory forwards SoundTouch's processing sample rate into the constructor for compatibility with the built-in `Stretch` contract, but the phase vocoder intentionally ignores that value.

## License

MPL-2.0 — see [LICENSE](../../LICENSE) for details.
17 changes: 17 additions & 0 deletions packages/stretch-phase-vocoder/src/PhaseVocoder.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,12 @@ describe('PhaseVocoder', () => {
const defaultPv = new PhaseVocoder();
expect(defaultPv.sampleReq).toBe(2048 / 4);
});

it('accepts a sampleRate option for Stretch compatibility', () => {
expect(
() => new PhaseVocoder({ sampleRate: 48000, fftSize: 512 }),
).not.toThrow();
});
});

describe('no-op guards', () => {
Expand Down Expand Up @@ -255,4 +261,15 @@ describe('createPhaseVocoderFactory', () => {
});
expect(pv.sampleReq).toBe(1024 / 4);
});

it('accepts the factory sample rate without changing algorithm behavior', () => {
const factory = createPhaseVocoderFactory(1024, 4);
const pv = factory(48000, {
sampleBufferFactory: () => ({} as import('@soundtouchjs/core').SampleBuffer),
sampleBufferType: 'circular',
});

expect(pv).toBeInstanceOf(PhaseVocoder);
expect(pv.sampleReq).toBe(1024 / 4);
});
});
14 changes: 8 additions & 6 deletions packages/stretch-phase-vocoder/src/PhaseVocoder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,14 @@ export type PhaseVocoderOverlapFactor = 2 | 4 | 8;
* Construction options for `PhaseVocoder`.
*
* @remarks
* `sampleBufferFactory` and `sampleBufferType` are passed through from
* `StretchFactoryOptions` when the vocoder is used as a `StretchPipe` inside
* `SoundTouch`. They are not used internally by the phase vocoder algorithm
* but are accepted for interface compatibility.
* `sampleRate`, `sampleBufferFactory`, and `sampleBufferType` are passed
* through from `StretchFactoryOptions` when the vocoder is used as a
* `StretchPipe` inside `SoundTouch`. They are not used internally by the
* phase vocoder algorithm but are accepted for interface compatibility.
*/
export interface PhaseVocoderOptions {
/** Ignored — accepted for constructor compatibility with the built-in Stretch stage. */
sampleRate?: number;
/** FFT frame size. Must be a power of two. @defaultValue 2048 */
fftSize?: PhaseVocoderFftSize;
/**
Expand Down Expand Up @@ -438,6 +440,6 @@ export function createPhaseVocoderFactory(
fftSize: PhaseVocoderFftSize = 2048,
overlapFactor: PhaseVocoderOverlapFactor = 4,
): StretchFactory {
return (_sampleRate, opts) =>
new PhaseVocoder({ fftSize, overlapFactor, ...opts });
return (sampleRate, opts) =>
new PhaseVocoder({ sampleRate, fftSize, overlapFactor, ...opts });
}
2 changes: 2 additions & 0 deletions storybook/src/docs/SoundTouch.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -91,4 +91,6 @@ const st = new SoundTouch({ stretchFactory: myFactory });

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.

When `stretchFactory` is omitted, `SoundTouch` constructs the built-in `Stretch` with that same sample rate up front.

> **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.
8 changes: 7 additions & 1 deletion storybook/src/docs/Stretch.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,15 @@ import { Stretch } from '@soundtouchjs/core';

```ts
new Stretch({
sampleRate?: number,
createBuffers?: boolean,
inputBufferAdapterFactory?: () => StretchReadBufferAdapter,
sampleBufferFactory?: () => SampleBuffer,
})
```

`sampleRate` seeds the internal WSOLA window sizes. If you already know the source rate, pass it here so the initial overlap and seek windows are calculated for the correct audio context from construction time.

## Public API

- tempo (getter/setter)
Expand All @@ -54,7 +57,10 @@ Apply a partial set of WSOLA timing parameters without replacing all values at o
import { Stretch } from '@soundtouchjs/core';
import type { StretchParameters } from '@soundtouchjs/core';

const stretch = new Stretch({ createBuffers: true });
const stretch = new Stretch({
createBuffers: true,
sampleRate: audioBuffer.sampleRate,
});

stretch.setStretchParameters({ overlapMs: 12 }); // overlap only
stretch.setStretchParameters({ quickSeek: false }); // exhaustive search
Expand Down
4 changes: 4 additions & 0 deletions storybook/src/docs/core/stretch-phase-vocoder.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ const st = new SoundTouch({

```ts
new PhaseVocoder({
sampleRate?: number, // accepted for Stretch compatibility; ignored
fftSize?: 512 | 1024 | 2048 | 4096, // default: 2048
overlapFactor?: 2 | 4 | 8, // default: 4
})
Expand All @@ -59,11 +60,14 @@ new PhaseVocoder({
<tr><th>Option</th><th>Default</th><th>Description</th></tr>
</thead>
<tbody>
<tr><td><code>sampleRate</code></td><td>ignored</td><td>Accepted for constructor/factory compatibility with <code>Stretch</code>; does not affect phase-vocoder processing</td></tr>
<tr><td><code>fftSize</code></td><td>2048</td><td>FFT frame size; larger = better frequency resolution, higher startup latency</td></tr>
<tr><td><code>overlapFactor</code></td><td>4</td><td>Overlap factor (75% overlap); higher = smoother at the cost of more FFT calls</td></tr>
</tbody>
</table>

When used through <code>createPhaseVocoderFactory()</code>, SoundTouch's processing sample rate is forwarded into the constructor for compatibility with the built-in <code>Stretch</code> contract, but the phase vocoder intentionally ignores it.

## Public API

<table>
Expand Down
26 changes: 14 additions & 12 deletions storybook/src/stories/AudioWorkletPlayground.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -85,16 +85,13 @@ const VOLUME_TICKS: readonly DatalistTick[] = [
{ value: 10, label: '10' },
];

const PLAYBACK_RATE_VALUES = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2] as const;

const RATE_TICKS: readonly DatalistTick[] = [
{ value: 0.1, label: '0.1' },
{ value: 0.5, label: '0.5' },
{ value: 1, label: '1' },
{ value: 1.5, label: '1.5' },
{ value: 2, label: '2' },
{ value: 2.5, label: '2.5' },
{ value: 3, label: '3' },
{ value: 3.5, label: '3.5' },
{ value: 4, label: '4' },
...PLAYBACK_RATE_VALUES.map((value) => ({
value,
label: String(value),
})),
];

const PITCH_TICKS: readonly DatalistTick[] = [
Expand Down Expand Up @@ -549,6 +546,7 @@ export function AudioWorkletPlayground({
const source = audioContextRef.current.createBufferSource();
source.buffer = audioBufferRef.current;
source.loop = loopEnabledRef.current;
source.playbackRate.value = rateRef.current;
source.connect(soundTouchNodeRef.current);
sourceNodeRef.current = source;
sourceOffsetRef.current = offset;
Expand Down Expand Up @@ -700,6 +698,10 @@ export function AudioWorkletPlayground({
if (elementRef.current) {
elementRef.current.playbackRate = rate;
}

if (sourceNodeRef.current) {
sourceNodeRef.current.playbackRate.value = rate;
}
}, [rate]);

useEffect(() => {
Expand Down Expand Up @@ -795,9 +797,9 @@ export function AudioWorkletPlayground({
{shouldShowRateControl ? (
<RangeControl
label="Playback Rate"
min={0.1}
max={4}
step={0.01}
min={PLAYBACK_RATE_VALUES[0]}
max={PLAYBACK_RATE_VALUES[PLAYBACK_RATE_VALUES.length - 1]}
step={0.25}
value={rate}
onChange={setRate}
ticks={RATE_TICKS}
Expand Down
16 changes: 13 additions & 3 deletions storybook/src/stories/audio-worklet-playground.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -88,13 +88,18 @@ export const PlaybackRate: Story = {
codeSample={`const context = new AudioContext();
await SoundTouchNode.register(context, processorModuleUrl);
const soundTouchNode = new SoundTouchNode({ context });
const source = context.createBufferSource();

source.connect(soundTouchNode);

const rateSlider = document.querySelector<HTMLInputElement>('#rate');
rateSlider?.addEventListener('input', (event) => {
const input = event.currentTarget as HTMLInputElement;
soundTouchNode.playbackRate.value = Number(input.value);
const rate = Number(input.value);
source.playbackRate.value = rate;
soundTouchNode.playbackRate.value = rate;
});`}
explanation="Playback rate changes overall transport speed. Use <1 for slow practice mode, >1 for fast review mode. For speech, small increments (1.0–1.5) usually sound best."
explanation="Playback rate changes overall transport speed. Keep source playbackRate and SoundTouchNode playbackRate in sync: source controls transport speed, while SoundTouch compensates pitch. Use <1 for slow practice mode and >1 for fast review mode."
/>
),
};
Expand Down Expand Up @@ -166,11 +171,16 @@ const soundTouchNode = new SoundTouchNode({
context,
interpolationStrategy: 'hann', // plugin strategies also supported
});
const source = context.createBufferSource();

source.connect(soundTouchNode);

const rateSlider = document.querySelector<HTMLInputElement>('#rate');
rateSlider?.addEventListener('input', (event) => {
const input = event.currentTarget as HTMLInputElement;
soundTouchNode.playbackRate.value = Number(input.value);
const rate = Number(input.value);
source.playbackRate.value = rate;
soundTouchNode.playbackRate.value = rate;
});`}
explanation="Lanczos is the default strategy. Linear is typically fastest, Hann is a balanced general-purpose option, Blackman improves stopband rejection, and Kaiser is tunable with zeroCrossings/beta style controls in plugin strategies."
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ const TRACKS = [
{ id: 'retrosoul', label: 'Bensound Retro Soul', url: retrosoulTrack },
];

const PLAYBACK_RATE_VALUES = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2] as const;

type RangeParamDef = { type?: 'range'; min: number; max: number; step: number; default: number };
type BooleanParamDef = { type: 'boolean'; default: boolean };
type SelectParamDef = { type: 'select'; options: string[]; default: string };
Expand Down Expand Up @@ -203,6 +205,7 @@ export const InterpolationPlaygroundBase = ({
const source = context.createBufferSource();
source.buffer = buffer;
source.loop = loopedRef.current;
source.playbackRate.value = playbackRateRef.current;
source.connect(stNode);
sourceNodeRef.current = source;
sourceOffsetRef.current = offset;
Expand Down Expand Up @@ -299,6 +302,7 @@ export const InterpolationPlaygroundBase = ({
useEffect(() => {
playbackRateRef.current = playbackRate;
if (soundTouchNodeRef.current) soundTouchNodeRef.current.playbackRate.value = playbackRate;
if (sourceNodeRef.current) sourceNodeRef.current.playbackRate.value = playbackRate;
}, [playbackRate]);

useEffect(() => {
Expand Down Expand Up @@ -391,17 +395,19 @@ export const InterpolationPlaygroundBase = ({
<label style={{ display: 'block' }}>
Playback rate: {playbackRate.toFixed(2)}
<input
type="range" min={0.1} max={4} step={0.01} value={playbackRate}
type="range"
min={PLAYBACK_RATE_VALUES[0]}
max={PLAYBACK_RATE_VALUES[PLAYBACK_RATE_VALUES.length - 1]}
step={0.25}
value={playbackRate}
list="rate-ticks"
onChange={(e) => setPlaybackRate(Number(e.target.value))}
style={{ display: 'block', width: '100%' }}
/>
<datalist id="rate-ticks">
<option value={0.1} label="0.1" />
<option value={0.5} label="0.5" />
<option value={1} label="1" />
<option value={2} label="2" />
<option value={4} label="4" />
{PLAYBACK_RATE_VALUES.map((value) => (
<option key={`rate-${value}`} value={value} label={String(value)} />
))}
</datalist>
</label>

Expand Down
Loading
Loading