Change a recording's duration and pitch with the available stretching controls, and understand the tradeoffs of each mode.
What it is#
Pitch-preserving sample playback — the honest fix for the "pitch drops when you slow it down" coupling that speed / warp / pitched all share. Three signal-first sample modifiers:
stretch r— change duration by ratiorwithout changing pitch (0.5= half-speed, an octave lower in pitch avoided).pitchShift cents— change pitch bycentswithout changing duration (700= up a fifth,-200= down two semitones).warpLock— the pitch-preserving counterpart ofwarp: fit the sample to the project tempo with the pitch held.
The DSP is a hybrid HPSS / STFT phase vocoder rendered offline on the engine's control thread — its own playback path, a sibling of Sample rather than a modifier on it.
Surface#
hold = adsr 1ms 2s 1 200ms -- one near-passthrough envelope
slowMo = patch { source = sample "amen" |> stretch 0.5, ampEnv = hold }
fifth = patch { source = sample "vocal" |> pitchShift 700, ampEnv = hold }
locked = patch { source = sample "break" |> warpLock, ampEnv = hold }
combo = patch { source = sample "amen" |> loop |> trim 0.0 0.5 |> stretch 0.75 |> pitchShift (-200),
ampEnv = hold }
Builtin signatures:
stretch : Sig Audio Bipolar -> Const Ratio -> Sig Audio Bipolar
pitchShift : Sig Audio Bipolar -> Const Cents -> Sig Audio Bipolar
warpLock : Sig Audio Bipolar -> Sig Audio Bipolar
pitchShift takes cents (the natural authoring axis for melodic intent — a negative literal needs parens: pitchShift (-200)); the runtime converts to a ratio.
Composition rule#
stretch / pitchShift / warpLock promote a sample chain onto the Stretch playback path. Modifiers that compose carry over: loop, trim, reverse. Modifiers whose DSP can't live on the phase vocoder error at lowering: speed, pitched, warp, chop, granular ("incompatible with … use pitchShift / warpLock instead"). stretch and pitchShift can compose with each other (each sets its own field).
IR#
A single sibling node carries the whole chain:
{ "op": "Stretch", "name": <sample>,
"time_ratio": <ratio>, "pitch_cents": <cents>, "warp_lock": <bool>,
"trim_start": <0..1>, "trim_end": <0..1>, "loop": <bool>, "reverse": <bool> }
Defaults when not set: time_ratio 1.0, pitch_cents 0.0, warp_lock false.
Runtime#
stretch::resolve_stretch(engine/src/stretch.rs) patches everyStretchnode in a module's synth templates with its pre-rendered buffer at load, memoised in aStretchCache. A node whose sample has not landed yet staysNone(silent) and is retried on the next recompile — the same contractWavetable/ConvolveIRs follow.warpLockcurrently renders no stretch.resolve_time_ratioreturns1.0wheneverwarp_lockis set, on the (now stale) grounds that the bank carries no source BPM —SampleBank::set_bpm/bpmhas since shipped forwarp, so the derivation is available but not wired. Write an explicitstretch runtil it is.- Note length: a non-looping
Stretchbumps its synth'smax_releaseso the voice rings the full stretched length (a 0.5 ratio plays twice as long); a loopingStretchrides the amp-envelope release. - The render is a hybrid HPSS/phase-vocoder (2026-08 audit E6, the DAFx-21 [30] recipe): a median-filter harmonic/percussive split (9-frame time median × 17-bin frequency median, Wiener masks; stereo takes ONE decision for both channels, from the per-bin
max(|L|, |R|)— the mid cancels an anti-correlated transient and cost the split half its benefit on a decorrelated drum bus, 2026-08-03 audit C14), the HARMONIC half through the STFT phase vocoder — FFT 4096, hop 1024, periodic Hann, identity phase locking + spectral-flux onset reset — and the PERCUSSIVE half relocated by a 256-point/50 % OLA at the same hop schedule (transients move without being phase-advanced: a lone click relocates as a single unit-amplitude copy). Ratio 1.0 (and 0-cent pitch) bypasses the split bit-identically. Pitch shift = stretch-then-resample through the polyphase Kaiser windowed-sinc (resample::resample_hq, ≥90 dB; the old 2-point lerp was −26 dB imaging). Length and time map (2026-08-03 audit C9, fixed 2026-08-05, sharpened 2026-08-06). A render is exactly
round(n · ratio)samples long — the MUSICAL length — and input timetlands at outputratio·t, the same law on both grids, so a looped and a non-looped render of the same material are the same duration. It used to shipround(n · hs/ha), the vocoder's integer-hop approximation of the ratio, which is exact only where the ratio is rational with a small denominator:stretch 2.5ran on 409/1022 and came out 2.45 ms long per 2 s bar (worst over a sweep 6.5e−4, and 17 samples on a pitch-shifted render, whose combined ratio is always ugly) — the same walk-off-the-grid defect C9 exists to fix, 40× smaller. The frame grid is now placed over the musical length rather than the other way round, andpv_hopssearches a quarter hop of slack for the integer pair closest to the requested ratio, which takes the CONTENT's own error from 6.5e−4 to 2.0e−5 (exact at 2.5, 0.3, 1.25, 1.75 and the 174/128warpLockshape). A LOOPED node gets there by closing its frame grid on the loop point and rotating the write by(ratio−1)·N/2; a NON-looped one by zero-padding the input at the front by a pad rounded up to whole hops and trimming that pad's image off the result, which turns the frame grid's centre map (ratio·(t − N/2) + N/2) into the plainratio·t. The grid then runs until the last frame starts past the end of the output, and the pad is sized so that output sample 0 clears a full window of frames too, so every returned sample sits under a fullΣw²overlap (minnorm/peak= 1.000 at every ratio from 0.25 to 8; a half-window pad left it at 0.583 at 0.25). What that does NOT remove is the residual level taper at each end, which is the analysis window genuinely running off the data and is not symmetric, because the two ends' images are scaled differently:ratio head tail 0.25 −4.98 dB −8.06 dB 0.5 −11.03 dB −0.84 dB 1 +0.01 dB −0.00 dB 2 −7.13 dB −1.75 dB 4 −4.25 dB −3.18 dB 8 −4.26 dB −4.13 dB A TRANSIENT at either end does not ride that curve: the harmonic/percussive split routes it to
x_p, which the short-window overlap-add places at full level (2026-08-06 review C9-3/C9-4). Before that a non-looped render came back(N/2)(ratio−1)short off the head, the same again off the tail, plus the image of the ≤hasamples that did not fill a whole final window — 92.9 ms at ratio 2, 278.6 ms at ratio 4, and 2048 samples LONG at ratio 0.5 — sostretch time 2on a one-bar loop gave 1.975 bars and drifted off the grid on every repeat, and a one-shot faded in and out over most of a window at each end (a steady tone's last 1024 samples measured −18.7 dB against its middle; a click one hop from the end came back at ⅓ level). At ratio ≤ 0.5 on a source under ~0.2 s the old grid never reached full window overlap at all — 7 frames where 8 are needed at hop 512 — so the whole buffer rode the norm floor. The pad sits on the hop grid on purpose: the rendered signal is bit-identical to the old one, only relocated (verified at ratios 1/1.5/2/4 on tone and noise, split on and off).
Refinement advisories#
Range warnings (not errors): time_ratio ⊂ [0.25, 4] ("extreme ratios produce metallic artifacts"), pitch_cents ⊂ [-2400, 2400].
v0 limitations#
- Phase-vocoder artifacts, largely addressed — peak-region phase locking, onset phase reset, and the HPSS percussive-relocation path (above) shipped 2026-08: sparse-click crest loss at ×2 improved from −8.6 dB to −5.0 dB and buzz-rate transient texture is preserved. Remaining known limit: click trains faster than ~20 Hz can't read as transients through a 4096-point analysis window (93 ms smear) — a resolution limit, not a bug.
- No formant preservation — extreme pitch shifts sound chipmunk / Darth-Vader.
- A looping
Stretchwraps with no crossfade — the rendered buffer IS the loop window, so neither side of the seam has partner material and the sampler's crossfade law (see The sampler § The loop seam) degenerates to a raw wrap:stretch … |> loopsteps by|x[n−1] − x[0]|once per buffer. Giving it a fade needsstretch.rsto render a guard region past the end (2026-08-03 audit D13). - No real-time-modulated ratio —
time_ratio/pitch_centsare baked into the rendered buffer at module load, so nothing retunes mid-note. - Per-event
begin/end(Control patterns for individual events) narrow the rendered buffer at playback, so the vocoder render is shared by every event: the buffer is thetrimwindow in playback order on the time mapt → ratio·t, so a fraction of it is the same fraction of the source window. The read seeds atbegin(a cut-in start takes the ≤128-frame note-on ramp), a one-shot ramps out over the same length at a cut-inendand stays silent after it, and a LOOPED node loops the sub-window with the sampler's automatic equal-gain seam fade — there is always material pastend, through the circular render — so the period stays exactlyend − begin. No lane (or one spanning the whole buffer) is the old read, bit for bit. Measured (engine/tests/sampling_gaps.rs):begin 0.5on a 300 Hz | 1200 Hz source stretched ×2 opens on 1200 Hz;end 0.25is silent from 0.55 s; a loopedbegin 0.37 |> end 0.61window on a 330 Hz tone has a max step 1.00× its p99 (18.8× with a raw wrap, 15.9× at the onset without the ramp). - CPU — the vocoder cost is paid once per
(sample, ratio, cents, loop)at module load (memoised in theStretchCache); playback itself is an ordinary buffer read.