Skip to content
Open
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
60 changes: 60 additions & 0 deletions packages/core/src/SoundTouch.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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', () => {
Expand Down
35 changes: 26 additions & 9 deletions packages/core/src/SoundTouch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
Expand Down
73 changes: 70 additions & 3 deletions packages/stretch-phase-vocoder/src/PhaseVocoder.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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', () => {
Expand All @@ -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', () => {
Expand Down
17 changes: 16 additions & 1 deletion packages/stretch-phase-vocoder/src/PhaseVocoder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}

/**
Expand Down