diff --git a/packages/core/src/SoundTouch.spec.ts b/packages/core/src/SoundTouch.spec.ts index f86068e..6c48f2a 100644 --- a/packages/core/src/SoundTouch.spec.ts +++ b/packages/core/src/SoundTouch.spec.ts @@ -59,6 +59,29 @@ class TestSampleBuffer implements SampleBuffer { } } +/** A StretchPipe that can only realize tempos on a multiple of `step`. */ +function makeSnappingStretch(step: number): StretchPipe { + let tempo = 1; + + return { + inputBuffer: null, + outputBuffer: null, + get tempo() { + return tempo; + }, + set tempo(t: number) { + tempo = Math.round(t / step) * step; + }, + sampleReq: 128, + clear: vi.fn(), + clearMidBuffer: vi.fn(), + process: vi.fn(), + setParameters: vi.fn(), + setStretchParameters: vi.fn(), + clone: vi.fn().mockReturnThis(), + }; +} + describe('SoundTouch', () => { describe('constructor', () => { it('initializes with default values', () => { @@ -210,6 +233,43 @@ describe('SoundTouch', () => { st.pitch = 0.5; expect(st.stretch.outputBuffer).toBe(st.outputBuffer); }); + + it('takes the rate from the tempo the stretch stage reports', () => { + // A stage that can only deliver tempos in quarter steps, standing in for a + // phase vocoder that advances its window by whole frames. The Transposer + // has to compress by whatever the stage actually stretched by, not by the + // pitch that was asked for, or the two disagree about duration. + const rateSpy = vi.spyOn(RateTransposer.prototype, 'rate', 'set'); + + try { + const st = new SoundTouch({ + stretchFactory: () => makeSnappingStretch(0.25), + }); + + st.pitch = 1.05; // tempo 0.952… snaps to 1.0 + + expect(st.stretch.tempo).toBe(1.0); + expect(rateSpy.mock.lastCall?.[0]).toBeCloseTo(1.0, 10); + } finally { + rateSpy.mockRestore(); + } + }); + + it('leaves the rate at the requested pitch when the stage honours it', () => { + // The built-in Stretch takes any tempo through its fractional skip + // accumulator, so reading the tempo back must not perturb the rate. + const rateSpy = vi.spyOn(RateTransposer.prototype, 'rate', 'set'); + + try { + const st = new SoundTouch({}); + + st.pitch = 1.05; + + expect(rateSpy.mock.lastCall?.[0]).toBe(1.05); + } finally { + rateSpy.mockRestore(); + } + }); }); describe('setStretchParameters', () => { diff --git a/packages/core/src/SoundTouch.ts b/packages/core/src/SoundTouch.ts index 92a1c57..370ef79 100644 --- a/packages/core/src/SoundTouch.ts +++ b/packages/core/src/SoundTouch.ts @@ -251,8 +251,10 @@ export default class SoundTouch { * Sets the pitch multiplier and recomputes the derived pipeline rate and tempo. * * @remarks - * Internally sets `_rate = pitch` and `_tempo = 1 / pitch`, rewiring the - * Transposer→Stretch stage order when pitch > 1. + * Requests `_tempo = 1 / pitch` of the Stretch stage and derives `_rate` from + * the tempo it reports back — `pitch` itself unless the stage rounded it — + * then rewires the Transposer→Stretch stage order when pitch > 1. See + * {@link SoundTouch.calculateEffectiveRateAndTempo}. */ set pitch(pitch: number) { this.virtualPitch = pitch; @@ -294,19 +296,34 @@ export default class SoundTouch { * Recomputes the effective pipeline rate/tempo from `virtualPitch` and rewires stage order when needed. * * @remarks - * `_rate` is set to `virtualPitch`; `_tempo` to `1 / virtualPitch`. When `_rate > 1` the - * Stretch stage feeds the Transposer; otherwise the order is reversed. + * `1 / virtualPitch` is requested of the Stretch stage, and `_rate` is taken + * from the tempo that stage reports back rather than from `virtualPitch` + * directly. A `StretchPipe` is only obliged to approximate the tempo it is + * given — a phase vocoder can advance its window by whole frames only — and + * the Transposer has to compress by whatever the stage actually stretched by. + * Reading it back keeps the two exact inverses; deriving both from + * `virtualPitch` independently lets them disagree about duration, which + * accumulates as output drifting against input for as long as the pipeline + * runs. The built-in `Stretch` honours any tempo through its fractional skip + * accumulator, so `_rate` stays exactly `virtualPitch` there. + * + * When `_rate > 1` the Stretch stage feeds the Transposer; otherwise the order + * is reversed. */ calculateEffectiveRateAndTempo(): void { const previousTempo = this._tempo; const previousRate = this._rate; + const requestedTempo = 1.0 / this.virtualPitch; - this._tempo = 1.0 / this.virtualPitch; - this._rate = this.virtualPitch; - - if (isFloatDifferent(this._tempo, previousTempo)) { - this.stretch.tempo = this._tempo; + if (isFloatDifferent(requestedTempo, previousTempo)) { + this.stretch.tempo = requestedTempo; } + + this._tempo = this.stretch.tempo; + this._rate = isFloatDifferent(this._tempo, requestedTempo) + ? 1.0 / this._tempo + : this.virtualPitch; + if (isFloatDifferent(this._rate, previousRate)) { this.transposer.rate = this._rate; } diff --git a/packages/stretch-phase-vocoder/src/PhaseVocoder.spec.ts b/packages/stretch-phase-vocoder/src/PhaseVocoder.spec.ts index a1dd9d6..56ada80 100644 --- a/packages/stretch-phase-vocoder/src/PhaseVocoder.spec.ts +++ b/packages/stretch-phase-vocoder/src/PhaseVocoder.spec.ts @@ -70,9 +70,14 @@ describe('PhaseVocoder', () => { expect(pv.tempo).toBe(1.0); }); - it('tempo setter updates the value', () => { + it('tempo setter snaps to the nearest realizable tempo', () => { + const Ha = pv.sampleReq; + pv.tempo = 0.75; - expect(pv.tempo).toBe(0.75); + + // only Ha / k is deliverable, so 0.75 lands on 128 / 171 + expect(pv.tempo).toBe(Ha / Math.round(Ha / 0.75)); + expect(pv.tempo).toBeCloseTo(0.75, 2); }); it('uses fftSize 2048 and overlapFactor 4 by default', () => { @@ -223,7 +228,7 @@ describe('PhaseVocoder', () => { it('clone inherits current tempo', () => { pv.tempo = 1.5; const c = pv.clone(); - expect(c.tempo).toBe(1.5); + expect(c.tempo).toBe(pv.tempo); }); it('clone is independent — setting tempo does not affect original', () => { @@ -232,6 +237,68 @@ describe('PhaseVocoder', () => { expect(pv.tempo).toBe(1.0); }); }); + + describe('realizable tempo', () => { + // The synthesis hop is a whole number of frames, so the stage can only + // deliver tempos of the form Ha / k. Reporting the snapped value is what + // lets SoundTouch match its Transposer rate to the stretch actually + // applied rather than to the pitch that was requested. + const requests = [0.7, 0.75, 0.9, 1.1, 1.25, 1.4, 1.5, 1.9]; + + it.each(requests)('reports a hop-aligned tempo for %f', (requested) => { + pv.tempo = requested; + + const hop = pv.sampleReq / pv.tempo; + + expect(hop).toBeCloseTo(Math.round(hop), 9); + }); + + it.each(requests)('stays within half a frame of %f', (requested) => { + pv.tempo = requested; + + const Ha = pv.sampleReq; + + expect(Math.abs(Ha / pv.tempo - Ha / requested)).toBeLessThanOrEqual(0.5); + }); + + it('is idempotent', () => { + pv.tempo = 1.37; + const once = pv.tempo; + + pv.tempo = once; + + expect(pv.tempo).toBe(once); + }); + + it.each([0.5, 1.0, 2.0])( + 'leaves exactly realizable tempo %f alone', + (t) => { + pv.tempo = t; + + expect(pv.tempo).toBe(t); + }, + ); + + it.each(requests)( + 'emits exactly the frames its reported tempo implies at %f', + (requested) => { + pv.tempo = requested; + + const Ha = pv.sampleReq; + const expectedPerHop = Ha / pv.tempo; + const hops = 24; + + for (let h = 0; h < hops; h++) { + fillBuffer(inputBuf, Ha); + pv.process(); + } + + // exact, not approximate: this is the invariant SoundTouch relies on to + // keep the Transposer rate matched to the stretch + expect(outputBuf.frameCount).toBe(expectedPerHop * hops); + }, + ); + }); }); describe('createPhaseVocoderFactory', () => { diff --git a/packages/stretch-phase-vocoder/src/PhaseVocoder.ts b/packages/stretch-phase-vocoder/src/PhaseVocoder.ts index ee2babb..9b0c1e5 100644 --- a/packages/stretch-phase-vocoder/src/PhaseVocoder.ts +++ b/packages/stretch-phase-vocoder/src/PhaseVocoder.ts @@ -156,13 +156,28 @@ export class PhaseVocoder implements StretchPipe { * Matches the convention of the WSOLA `Stretch` stage: values greater than 1 * speed up playback (shorter output) and values less than 1 slow it down * (longer output). The synthesis hop is derived as `round(Ha / tempo)`. + * + * Because that hop is a whole number of frames, the only tempos this stage can + * actually deliver are `Ha / k` for integer `k`. The setter snaps to the + * nearest of them and the getter reports the snapped value, so a caller — or + * `SoundTouch`, which reads it back to set its Transposer rate — sees what the + * stage will really do rather than assuming the request was met exactly. Left + * unsnapped, the stage stretches by up to `1 / (2 * Ha)` more or less than the + * Transposer compresses by, and the pipeline's output drifts against its input + * for as long as it runs. Snapping costs that same fraction of pitch accuracy, + * roughly 0.02 semitones at the default settings. + * + * Absorbing the remainder by varying the hop from frame to frame is the other + * way to close the gap, and a worse one: it jitters the synthesis rotation and + * measurably decoheres the phase recursion. */ get tempo(): number { return this._tempo; } set tempo(t: number) { - this._tempo = t; + const hop = Math.round(this.analysisHop / t); + this._tempo = this.analysisHop / Math.max(1, Math.min(hop, this.fftSize)); } /**