VOLTAreference

Reference / Samples and recording

Time-stretching and pitch-shifting

Samples and recording

Open in the workbench →
On this page · 6 sections

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 ratio r without changing pitch (0.5 = half-speed, an octave lower in pitch avoided).
  • pitchShift cents — change pitch by cents without changing duration (700 = up a fifth, -200 = down two semitones).
  • warpLock — the pitch-preserving counterpart of warp: 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 every Stretch node in a module's synth templates with its pre-rendered buffer at load, memoised in a StretchCache. A node whose sample has not landed yet stays None (silent) and is retried on the next recompile — the same contract Wavetable / Convolve IRs follow.
  • warpLock currently renders no stretch. resolve_time_ratio returns 1.0 whenever warp_lock is set, on the (now stale) grounds that the bank carries no source BPM — SampleBank::set_bpm / bpm has since shipped for warp, so the derivation is available but not wired. Write an explicit stretch r until it is.
  • Note length: a non-looping Stretch bumps its synth's max_release so the voice rings the full stretched length (a 0.5 ratio plays twice as long); a looping Stretch rides 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 time t lands at output ratio·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 ship round(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.5 ran 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, and pv_hops searches 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/128 warpLock shape). 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 plain ratio·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 (min norm/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:

    ratioheadtail
    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 ≤ ha samples 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 — so stretch time 2 on 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 Stretch wraps 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 … |> loop steps by |x[n−1] − x[0]| once per buffer. Giving it a fade needs stretch.rs to render a guard region past the end (2026-08-03 audit D13).
  • No real-time-modulated ratio — time_ratio / pitch_cents are 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 the trim window in playback order on the time map t → ratio·t, so a fraction of it is the same fraction of the source window. The read seeds at begin (a cut-in start takes the ≤128-frame note-on ramp), a one-shot ramps out over the same length at a cut-in end and 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 past end, through the circular render — so the period stays exactly end − begin. No lane (or one spanning the whole buffer) is the old read, bit for bit. Measured (engine/tests/sampling_gaps.rs): begin 0.5 on a 300 Hz | 1200 Hz source stretched ×2 opens on 1200 Hz; end 0.25 is silent from 0.55 s; a looped begin 0.37 |> end 0.61 window 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 the StretchCache); playback itself is an ordinary buffer read.