diff --git a/MEMORY.md b/MEMORY.md new file mode 100644 index 0000000..5c0e4f4 --- /dev/null +++ b/MEMORY.md @@ -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. diff --git a/packages/core/README.md b/packages/core/README.md index e3a589c..6036ff5 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -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: @@ -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) diff --git a/packages/core/src/SoundTouch.spec.ts b/packages/core/src/SoundTouch.spec.ts index 47b40ae..f86068e 100644 --- a/packages/core/src/SoundTouch.spec.ts +++ b/packages/core/src/SoundTouch.spec.ts @@ -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); diff --git a/packages/core/src/SoundTouch.ts b/packages/core/src/SoundTouch.ts index d4d3a9d..92a1c57 100644 --- a/packages/core/src/SoundTouch.ts +++ b/packages/core/src/SoundTouch.ts @@ -144,6 +144,7 @@ export default class SoundTouch { }); } else { this.stretch = new Stretch({ + sampleRate: this._sampleRate, createBuffers: false, inputBufferAdapterFactory: this._sampleBufferType === 'circular' diff --git a/packages/core/src/Stretch.spec.ts b/packages/core/src/Stretch.spec.ts index bda6403..0d9b791 100644 --- a/packages/core/src/Stretch.spec.ts +++ b/packages/core/src/Stretch.spec.ts @@ -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); diff --git a/packages/core/src/Stretch.ts b/packages/core/src/Stretch.ts index 566658e..c08401d 100644 --- a/packages/core/src/Stretch.ts +++ b/packages/core/src/Stretch.ts @@ -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. */ @@ -426,6 +431,7 @@ export default class Stretch * @param options Constructor options. */ constructor({ + sampleRate = 44100, createBuffers = false, inputBufferAdapterFactory = createFifoStretchInputBufferAdapter, sampleBufferFactory = () => new FifoSampleBuffer(), @@ -452,7 +458,7 @@ export default class Stretch this._tempo = 1; this.setParameters( - 44100, + sampleRate, DEFAULT_SEQUENCE_MS, DEFAULT_SEEKWINDOW_MS, DEFAULT_OVERLAP_MS, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index f915cb1..9972a9f 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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, diff --git a/packages/stretch-phase-vocoder/README.md b/packages/stretch-phase-vocoder/README.md index 22b5e1c..4897df1 100644 --- a/packages/stretch-phase-vocoder/README.md +++ b/packages/stretch-phase-vocoder/README.md @@ -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. diff --git a/packages/stretch-phase-vocoder/src/PhaseVocoder.spec.ts b/packages/stretch-phase-vocoder/src/PhaseVocoder.spec.ts index 29fb15c..a1dd9d6 100644 --- a/packages/stretch-phase-vocoder/src/PhaseVocoder.spec.ts +++ b/packages/stretch-phase-vocoder/src/PhaseVocoder.spec.ts @@ -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', () => { @@ -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); + }); }); diff --git a/packages/stretch-phase-vocoder/src/PhaseVocoder.ts b/packages/stretch-phase-vocoder/src/PhaseVocoder.ts index e5cb422..ee2babb 100644 --- a/packages/stretch-phase-vocoder/src/PhaseVocoder.ts +++ b/packages/stretch-phase-vocoder/src/PhaseVocoder.ts @@ -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; /** @@ -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 }); } diff --git a/storybook/src/docs/SoundTouch.mdx b/storybook/src/docs/SoundTouch.mdx index 63ed58f..6146a3a 100644 --- a/storybook/src/docs/SoundTouch.mdx +++ b/storybook/src/docs/SoundTouch.mdx @@ -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. diff --git a/storybook/src/docs/Stretch.mdx b/storybook/src/docs/Stretch.mdx index fe82d17..158ed6d 100644 --- a/storybook/src/docs/Stretch.mdx +++ b/storybook/src/docs/Stretch.mdx @@ -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) @@ -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 diff --git a/storybook/src/docs/core/stretch-phase-vocoder.mdx b/storybook/src/docs/core/stretch-phase-vocoder.mdx index f0213c6..e3f1b0e 100644 --- a/storybook/src/docs/core/stretch-phase-vocoder.mdx +++ b/storybook/src/docs/core/stretch-phase-vocoder.mdx @@ -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 }) @@ -59,11 +60,14 @@ new PhaseVocoder({ OptionDefaultDescription + sampleRateignoredAccepted for constructor/factory compatibility with Stretch; does not affect phase-vocoder processing fftSize2048FFT frame size; larger = better frequency resolution, higher startup latency overlapFactor4Overlap factor (75% overlap); higher = smoother at the cost of more FFT calls +When used through createPhaseVocoderFactory(), SoundTouch's processing sample rate is forwarded into the constructor for compatibility with the built-in Stretch contract, but the phase vocoder intentionally ignores it. + ## Public API diff --git a/storybook/src/stories/AudioWorkletPlayground.tsx b/storybook/src/stories/AudioWorkletPlayground.tsx index abe5b05..f6c5e2e 100644 --- a/storybook/src/stories/AudioWorkletPlayground.tsx +++ b/storybook/src/stories/AudioWorkletPlayground.tsx @@ -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[] = [ @@ -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; @@ -700,6 +698,10 @@ export function AudioWorkletPlayground({ if (elementRef.current) { elementRef.current.playbackRate = rate; } + + if (sourceNodeRef.current) { + sourceNodeRef.current.playbackRate.value = rate; + } }, [rate]); useEffect(() => { @@ -795,9 +797,9 @@ export function AudioWorkletPlayground({ {shouldShowRateControl ? ( ('#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." /> ), }; @@ -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('#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." /> diff --git a/storybook/src/stories/interpolation-strategies/InterpolationPlaygroundBase.tsx b/storybook/src/stories/interpolation-strategies/InterpolationPlaygroundBase.tsx index b1da832..4dd4f8d 100644 --- a/storybook/src/stories/interpolation-strategies/InterpolationPlaygroundBase.tsx +++ b/storybook/src/stories/interpolation-strategies/InterpolationPlaygroundBase.tsx @@ -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 }; @@ -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; @@ -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(() => { @@ -391,17 +395,19 @@ export const InterpolationPlaygroundBase = ({ diff --git a/storybook/src/stories/interpolation-strategies/InterpolationStrategyPlayground.tsx b/storybook/src/stories/interpolation-strategies/InterpolationStrategyPlayground.tsx index 0222485..071a6dc 100644 --- a/storybook/src/stories/interpolation-strategies/InterpolationStrategyPlayground.tsx +++ b/storybook/src/stories/interpolation-strategies/InterpolationStrategyPlayground.tsx @@ -81,16 +81,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[] = [ @@ -358,6 +355,7 @@ export function InterpolationStrategyPlayground({ 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; @@ -445,6 +443,10 @@ export function InterpolationStrategyPlayground({ useEffect(() => { rateRef.current = rate; + + if (sourceNodeRef.current) { + sourceNodeRef.current.playbackRate.value = rate; + } }, [rate]); useEffect(() => { @@ -552,9 +554,9 @@ export function InterpolationStrategyPlayground({