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({
sampleRateStretch; does not affect phase-vocoder processingfftSizeoverlapFactorcreatePhaseVocoderFactory(), 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