Skip to content

fix(core): Match the transposer rate to the stretch stage's realized tempo - #85

Open
bhj wants to merge 1 commit into
cutterbl:masterfrom
bhj:fix/stretch-tempo-realized-rate
Open

fix(core): Match the transposer rate to the stretch stage's realized tempo#85
bhj wants to merge 1 commit into
cutterbl:masterfrom
bhj:fix/stretch-tempo-realized-rate

Conversation

@bhj

@bhj bhj commented Jul 28, 2026

Copy link
Copy Markdown

NOTE: This will conflict with the changes in #84 but I can rebase if that gets merged first. Thanks.

Model used: Anthropic Opus 5

SoundTouch asks the Stretch stage for 1 / pitch and, independently, sets the Transposer to pitch, assuming the stage delivers the tempo it was given. The built-in Stretch does, via its fractional skipFract accumulator. PhaseVocoder does not: its synthesis hop is round(Ha / tempo) whole frames, so it realizes Ha / round(Ha / tempo) instead — off by up to 1 / (2 * Ha), or 0.11% at the default 2048/4.

The stages then disagree about duration, and the error accumulates for as long as the pipeline runs: roughly 270ms of slip over a four-minute track. A real-time consumer feels it either as latency creeping upward or as the output buffer draining until it hands back silence, depending on which way the hop rounded.

Two halves:

  • PhaseVocoder snaps tempo to a hop it can actually deliver and reports the snapped value, so the stage no longer claims a tempo it will not honour. Costs the same fraction of pitch accuracy it used to lose to drift, about 0.02 semitones, which is inaudible.
  • SoundTouch derives _rate from the tempo the stage reports rather than from virtualPitch, via the existing public StretchPipe.tempo getter. No interface change. The built-in Stretch returns what it was given, so _rate stays exactly virtualPitch there and that path is untouched.

Two PhaseVocoder specs asserted the old exact tempo round-trip and now assert the snapped value.

…tempo

SoundTouch asks the Stretch stage for `1 / pitch` and, independently, sets the
Transposer to `pitch`, assuming the stage delivers the tempo it was given. The
built-in Stretch does, via its fractional skipFract accumulator. PhaseVocoder
does not: its synthesis hop is `round(Ha / tempo)` whole frames, so it realizes
`Ha / round(Ha / tempo)` instead — off by up to `1 / (2 * Ha)`, or 0.11% at the
default 2048/4.

The stages then disagree about duration, and the error accumulates for as long
as the pipeline runs: roughly 270ms of slip over a four-minute track. A
real-time consumer feels it either as latency creeping upward or as the output
buffer draining until it hands back silence, depending on which way the hop
rounded.

Two halves:

- PhaseVocoder snaps `tempo` to a hop it can actually deliver and reports the
  snapped value, so the stage no longer claims a tempo it will not honour.
  Costs the same fraction of pitch accuracy it used to lose to drift, about
  0.02 semitones, which is inaudible.
- SoundTouch derives `_rate` from the tempo the stage reports rather than from
  virtualPitch, via the existing public StretchPipe.tempo getter. No interface
  change. The built-in Stretch returns what it was given, so `_rate` stays
  exactly virtualPitch there and that path is untouched.

Absorbing the remainder with a fractional hop inside the vocoder was the other
candidate and measured worse: varying the hop between frames jitters the
synthesis rotation and decoheres the phase recursion, costing over a dB of
broadband level — more than the drift it fixes.

Two PhaseVocoder specs asserted the old exact tempo round-trip and now assert
the snapped value.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant