Merge Γ-W1-T5: a real Preserve time-stretcher — write rate is duration, tap rate is pitch
This commit is contained in:
@@ -287,7 +287,9 @@ anything for a trigger shape.
|
||||
- `voice.h` / `voice.cpp` — one voice. The per-SAMPLE render half (`advanceFrame` and everything it calls) is INLINE IN THE HEADER by RT constraint; the per-NOTE half (note-on setup incl. the Preserve ring prime, legato retune, gate-off, the off-thread shifter presize) is out of line in the TU. The voice owns its own `VoiceFilter` and filter envelope, run between the pitch stage and the amp multiply — see `engine/filter/CLAUDE.md`. **Documented ~600-line-ceiling exception** (root `CLAUDE.md` structural heuristic 1): `voice.h` sits over the ceiling because `advanceFrame`'s RT-inline constraint forbids the seam a split would need — a documented exception, not silent overshoot.
|
||||
- `voice_engine.h` / `voice_engine.cpp` — `VoiceEngine`: note routing, bounded-stealing allocation, user-parameterized voice count (1–32, default 16), `VoiceMode` Poly/Mono (last-note held-note stack, `MonoTrigger` Retrigger/Legato), two-tier panic (CC 123 = all-notes-off release, CC 120 = immediate hard-stop including Trigger one-shots), and the block render loops. Preview injects a synthetic note-on at the loaded capture's root note into the main `VoiceEngine` — no dedicated `PreviewCard`; preview obeys polyphony/mono/voice-stealing/envelopes.
|
||||
- `engine/loop/` — the sustain loop's ONE validity/clamp fold (`resolveLoop`) plus its pre-seam crossfade geometry and the editor's default handle span; see `engine/loop/CLAUDE.md`. The voice folds it once at note-on; the crossfade weight is header-inline because it rides the per-sample read.
|
||||
- `pitch_shift` — hand-rolled **correlation-aligned SOLA** (splice-overlap-add) pitch shifter for the Preserve playback mode: one active read tap chases the write head at the shift ratio; each splice jump is refined by a cross-correlation search so the new read point is waveform-aligned, then old and new taps are crossfaded (raised-cosine, amplitude-complementary). Replaces the prior dual-tap OLA whose fixed half-window tap offset caused anti-phase cancellation on many source frequencies. **GA2:** ring buffer **primed with the actual upcoming source** at note-on (was zero-filled) → gap-free frame-0 onset, ~25 ms Preserve onset latency eliminated (Preserve now speaks on frame 0, matching Varispeed), and real-content-bounded tail (last-window tail-truncation gone). No third-party dependencies; RT-discipline: no allocation in `process()`.
|
||||
- `pitch_shift` — hand-rolled **correlation-aligned SOLA** (splice-overlap-add) pitch shifter AND time-stretcher for the Preserve playback mode: one active read tap chases the write head at the shift ratio; each splice jump is refined by a cross-correlation search so the new read point is waveform-aligned, then old and new taps are crossfaded (raised-cosine, amplitude-complementary). Replaces the prior dual-tap OLA whose fixed half-window tap offset caused anti-phase cancellation on many source frequencies. **GA2:** ring buffer **primed with the actual upcoming source** at note-on (was zero-filled) → gap-free frame-0 onset, ~25 ms Preserve onset latency eliminated (Preserve now speaks on frame 0, matching Varispeed), and real-content-bounded tail (last-window tail-truncation gone). No third-party dependencies; RT-discipline: no allocation in `process()`.
|
||||
- **The WRITE rate (duration) and the TAP rate (pitch) are independent, and that is the whole time-stretcher** — `writeFrame` for a surplus source frame, `processNoInput` for a starved output frame, plain `process` for the 1:1 case, `setShiftRatio` for pitch, and `setFeedRate` so the splice crossfade is sized against the real drain rate. The header owns the argument, including why this is not the resampled-read-with-a-cancelling-shift the `WDL_Resampler` invariant above forbids.
|
||||
- `time_stretch` — the TIME half beside `pitch_shift`'s PITCH half, header-only: `StretchCursor`, the per-output-frame source-feed schedule (a fractional cursor carrying its rate debt, loop-wrapped), plus the rate bounds and their clamp. Rate 1.0 is exactly one source frame per output frame with no residue, which is what makes the unity Preserve read bit-identical to the pre-stretch engine. The bounds are **measured**, not arbitrary — see the header.
|
||||
- `velocity_curve` — THE monotone spline, shared by every consumer: the three velocity transfer curves and the three spline EGs. `VelocityCurve` is evaluated as ONE OR MORE Fritsch–Carlson monotone cubic Hermite splines joined at its HARD points — a hard knot is a sub-curve boundary for tangent purposes (exactly what the point array's own ends already are), so the two adjacent segments meet at their natural angle instead of a shared derivative and the no-overshoot guarantee holds PER SEGMENT rather than globally. Points are smooth by default; the ceiling is `kMaxCurvePoints` = 128, a MUSICAL bound (long rhythmic phrases, ~two points per articulation event) and not a performance one — **do not lower it**. `eval(velocity)` is the COLD reader, called once per note-on or once per drawn pixel column; `SplineCursor` is the RT one, an indexed segment search plus one Hermite evaluation with the segment and its tangents cached across samples. Both share the same `segmentTangents`/`hermiteAt` free functions, so there is one spline and not two. It carries its own y `CurveDomain`: UNIPOLAR [0,1] is the amp's GAIN, defaulting to `flat()` (y=1, every velocity→unity — a deliberate non-back-compat replacement of the old fixed `velocity/127` path, Daniel-approved); BIPOLAR [−1,1] is the signed modulation shape for pitch and filter, defaulting to `zero()` so velocity modulates neither until a curve is drawn. A bipolar curve does not imply the absence of a depth beside it: the filter keeps its `velAmount` knob and the two compose multiplicatively (`velAmount × curve.eval(v)`, `play_params.h`), while the pitch curve's throw is the fixed `kVelocityPitchRangeSemitones`.
|
||||
- `master_gain` — pure dB↔linear taper math (FB1): normalized [0,1] ↔ dB ↔ linear for the post-mixer master gain control (−∞…+24 dB, norm 0 = true silence, unity ≈ 0.714). Shared by the editor knob and the processor multiply so the needle, persisted value, and audio multiply cannot drift.
|
||||
|
||||
|
||||
@@ -32,7 +32,8 @@ reasampler_test(live_params LINK live_params)
|
||||
# boundary costs the hot path nothing.
|
||||
reasampler_pure_library(sampler_core
|
||||
SOURCES voice.cpp voice_engine.cpp
|
||||
LINK PUBLIC peaks pitch_shift velocity_curve filter live_params curve_law loop_span)
|
||||
LINK PUBLIC peaks pitch_shift velocity_curve filter live_params curve_law loop_span
|
||||
time_stretch)
|
||||
# Links only sampler_core: linking more would break the plain-data-boundary proof — a VST3
|
||||
# or REAPER type reaching the core would fail to compile or link here.
|
||||
reasampler_test(sampler_core LINK sampler_core)
|
||||
@@ -48,3 +49,20 @@ reasampler_test(live_delivery LINK sampler_core)
|
||||
# The staged-envelope system across the same engine: per-segment curves, the sustain-less AHD
|
||||
# both mode shapes share, and the Trigger tail's terminal behaviour.
|
||||
reasampler_test(staged_envelopes LINK sampler_core)
|
||||
|
||||
# Measurement harness for Preserve on low-frequency material: how the splice search's
|
||||
# reachable relocation interval interacts with a long source period. Written longhand and
|
||||
# deliberately NOT add_test()'d — it sweeps frequencies, windows and spectra and takes ~2m40s
|
||||
# in Debug, which does not belong in a gate whose other targets run in seconds. It still
|
||||
# builds with everything else, so it cannot rot into non-compilation. Run it by hand, in
|
||||
# Release, when the question is what Preserve does to a given frequency.
|
||||
add_executable(preserve_low_frequency_tests
|
||||
${REASAMPLER_TESTS_DIR}/test_preserve_low_frequency.cpp)
|
||||
target_link_libraries(preserve_low_frequency_tests PRIVATE sampler_core)
|
||||
|
||||
# The Preserve read's source-feed schedule — the TIME half beside pitch_shift's PITCH half.
|
||||
# Header-only (it sits on the per-sample feed), hence INTERFACE.
|
||||
add_library(time_stretch INTERFACE)
|
||||
target_include_directories(time_stretch INTERFACE ${REASAMPLER_SRC_DIR})
|
||||
target_link_libraries(time_stretch INTERFACE loop_span)
|
||||
reasampler_test(time_stretch LINK time_stretch)
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
// pitch_shift — pure implementation. See pitch_shift.h for the contract and regression history.
|
||||
//
|
||||
// Algorithm: a delay ring of 2*window frames. The write head advances one frame per input
|
||||
// sample (source rate, duration preserved). One active read tap advances by the shift
|
||||
// `ratio_` per frame, so its delay behind the writer drifts at (1 - ratio) per frame. When
|
||||
// Algorithm: a delay ring of 2*window frames. The write head advances one frame per source
|
||||
// frame the caller feeds; the active read tap advances by the shift `ratio_` per OUTPUT frame,
|
||||
// so its delay behind the writer drifts at (feedRate - ratio) per frame — one frame in, one
|
||||
// frame out (`feedRate == 1`) preserves duration, and any other feed cadence stretches it. When
|
||||
// that delay leaves the safe band [dLow, dHigh], the tap is relocated by a nominal jump of
|
||||
// one window — clamped to the filled span so it never lands in unwritten silence — refined
|
||||
// by a cross-correlation search over +/- maxLag plus a parabolic peak interpolation for a
|
||||
@@ -38,6 +39,7 @@ void PitchShifter::configure(std::int64_t windowFrames) {
|
||||
fadeFrames_ = fadeLen_ = maxLag_ = corrFrames_ = dLow_ = dHigh_ = 0;
|
||||
filled_ = 0;
|
||||
ratio_ = 1.0;
|
||||
feedRate_ = 1.0;
|
||||
tailFrozen_ = false;
|
||||
lastSplice_ = SpliceEvent{};
|
||||
return;
|
||||
@@ -85,6 +87,7 @@ void PitchShifter::reset() {
|
||||
}
|
||||
filled_ = 0;
|
||||
ratio_ = 1.0;
|
||||
feedRate_ = 1.0;
|
||||
tailFrozen_ = false;
|
||||
lastSplice_ = SpliceEvent{};
|
||||
}
|
||||
@@ -93,7 +96,7 @@ void PitchShifter::freezeTail() {
|
||||
if (window_ <= 1 || tailFrozen_) return;
|
||||
tailFrozen_ = true;
|
||||
// An in-flight crossfade was sized for a retreating writer (outgoing tap drains at
|
||||
// ratio-1 per frame); frozen, it closes at the full ratio instead. Cap the live fade so
|
||||
// ratio-feedRate per frame); frozen, it closes at the full ratio instead. Cap the live fade so
|
||||
// it completes before tap B reaches the parked writer and reads lapped content mid-fade.
|
||||
if (fading_) {
|
||||
// Preserve t = fadePos_/fadeLen_ across the shortening so gNew is continuous at the
|
||||
@@ -158,6 +161,10 @@ void PitchShifter::setShiftRatio(double ratio) {
|
||||
if (ratio > 0.0) ratio_ = ratio; // ignore non-positive (never run the tap backward/stall)
|
||||
}
|
||||
|
||||
void PitchShifter::setFeedRate(double rate) {
|
||||
if (rate > 0.0) feedRate_ = rate;
|
||||
}
|
||||
|
||||
double PitchShifter::readTap(double pos) const {
|
||||
// Fractional linear interpolation with ring wrap.
|
||||
double p = pos;
|
||||
@@ -266,18 +273,19 @@ void PitchShifter::splice(std::int64_t nominalJump, double delay) {
|
||||
while (p >= len) p -= len;
|
||||
posA_ = p;
|
||||
// Ratio-scaled fade length. At an up-splice the outgoing tap keeps draining toward the
|
||||
// writer at (ratio - 1) per frame; the nominal window/4 fade only keeps it behind the
|
||||
// writer for ratios up to 2 — beyond that (e.g. +24 st = ratio 4) it would cross mid-fade
|
||||
// and play stale read-ahead data. Cap the live fade at the drain headroom actually
|
||||
// available, minus 2 (trigger undershoot + interpolator read-ahead margin). Down-shifts
|
||||
// drain at (1 - ratio) < 1 per frame and can't reach the ring end within window/4 frames,
|
||||
// so they always keep the full fade.
|
||||
// writer at (ratio - feedRate) per frame; the nominal window/4 fade only keeps it behind
|
||||
// the writer while that rate stays under ~1 — beyond that (e.g. +24 st = ratio 4, or a
|
||||
// half-speed feed under any up-shift) it would cross mid-fade and play stale read-ahead
|
||||
// data. Cap the live fade at the drain headroom actually available, minus 2 (trigger
|
||||
// undershoot + interpolator read-ahead margin). A drain rate at or below zero (down-shifts,
|
||||
// and up-shifts the feed outruns) can't reach the ring end within window/4 frames, so those
|
||||
// always keep the full fade.
|
||||
//
|
||||
// Tail-frozen: with the writer parked, the outgoing tap closes on it at the full ratio in
|
||||
// either shift direction, so the drain rate is ratio_ instead of (ratio_ - 1) and the cap
|
||||
// applies at every ratio (including unity, since delay now drains at unity too).
|
||||
// either shift direction, so the drain rate is ratio_ regardless of feed and the cap applies
|
||||
// at every ratio (including unity, since delay now drains at unity too).
|
||||
fadeLen_ = fadeFrames_;
|
||||
const double drainRate = tailFrozen_ ? ratio_ : (ratio_ - 1.0);
|
||||
const double drainRate = tailFrozen_ ? ratio_ : (ratio_ - feedRate_);
|
||||
if (drainRate > 0.0) {
|
||||
const double headroom = static_cast<double>(dLow_) - drainRate - 2.0;
|
||||
// Clamp in double before the int64 cast to avoid UB at pathological near-unity ratios
|
||||
@@ -310,14 +318,27 @@ void PitchShifter::applySplice(const SpliceEvent& ev) {
|
||||
lastSplice_ = ev; // observable mirror (tests assert follower == master per frame)
|
||||
}
|
||||
|
||||
AudioSample PitchShifter::process(AudioSample in) { return processImpl(in, nullptr); }
|
||||
AudioSample PitchShifter::process(AudioSample in) { return processImpl(in, nullptr, true); }
|
||||
|
||||
AudioSample PitchShifter::processLinked(AudioSample in, const SpliceEvent& master) {
|
||||
return processImpl(in, &master);
|
||||
return processImpl(in, &master, true);
|
||||
}
|
||||
|
||||
AudioSample PitchShifter::processImpl(AudioSample in, const SpliceEvent* linked) {
|
||||
if (window_ <= 1) return in; // pass-through (unconfigured / degenerate)
|
||||
AudioSample PitchShifter::processNoInput() { return processImpl(0.0f, nullptr, false); }
|
||||
|
||||
AudioSample PitchShifter::processNoInputLinked(const SpliceEvent& master) {
|
||||
return processImpl(0.0f, &master, false);
|
||||
}
|
||||
|
||||
void PitchShifter::writeFrame(AudioSample in) {
|
||||
if (window_ <= 1 || tailFrozen_) return;
|
||||
ring_[static_cast<std::size_t>(writePos_)] = in;
|
||||
if (filled_ < ringLen_) ++filled_;
|
||||
if (++writePos_ >= ringLen_) writePos_ = 0;
|
||||
}
|
||||
|
||||
AudioSample PitchShifter::processImpl(AudioSample in, const SpliceEvent* linked, bool write) {
|
||||
if (window_ <= 1) return write ? in : 0.0f; // pass-through (unconfigured / degenerate)
|
||||
|
||||
// Copy the linked decision before clearing lastSplice_ (guards a self-aliased pointer).
|
||||
const SpliceEvent linkedEv = linked != nullptr ? *linked : SpliceEvent{};
|
||||
@@ -325,8 +346,9 @@ AudioSample PitchShifter::processImpl(AudioSample in, const SpliceEvent* linked)
|
||||
|
||||
// Tail-frozen: the source is exhausted, `in` is padding, not stream — write nothing (the
|
||||
// ring keeps its all-real final two windows) and hold the write head; read/splice/fade
|
||||
// below run unchanged over the frozen content.
|
||||
if (!tailFrozen_) {
|
||||
// below run unchanged over the frozen content. A starved stretch frame (`write` false) takes
|
||||
// the identical shape: no input was due this output frame, so there is nothing to write.
|
||||
if (write && !tailFrozen_) {
|
||||
ring_[static_cast<std::size_t>(writePos_)] = in;
|
||||
if (filled_ < ringLen_) ++filled_;
|
||||
}
|
||||
@@ -378,8 +400,9 @@ AudioSample PitchShifter::processImpl(AudioSample in, const SpliceEvent* linked)
|
||||
}
|
||||
}
|
||||
|
||||
// Advance heads: write head one frame (parked while tail-frozen), tap(s) by the shift ratio.
|
||||
if (!tailFrozen_) {
|
||||
// Advance heads: write head one frame (parked while tail-frozen or starved), tap(s) by the
|
||||
// shift ratio.
|
||||
if (write && !tailFrozen_) {
|
||||
++writePos_;
|
||||
if (writePos_ >= ringLen_) writePos_ = 0;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
#pragma once
|
||||
// pitch_shift — per-voice, duration-preserving pitch shifter (the Preserve engine's DSP core).
|
||||
// pitch_shift — per-voice pitch shifter and time-stretcher (the Preserve engine's DSP core).
|
||||
// Time-domain delay-line with correlation-aligned splices (SOLA-style): one active read tap
|
||||
// chases the write head at the shift ratio; when it drifts out of its safe delay band it is
|
||||
// relocated by a nominal window jump, refined by a cross-correlation search so the new read
|
||||
// point is waveform-aligned, then old/new taps crossfade (raised-cosine). Source and output are
|
||||
// both consumed/produced 1:1 — only pitch changes, duration is held (unlike the Varispeed
|
||||
// `readPos_ += ratio_` resample path).
|
||||
// point is waveform-aligned, then old/new taps crossfade (raised-cosine).
|
||||
//
|
||||
// The WRITE rate (how fast source is consumed = duration) and the TAP rate (setShiftRatio =
|
||||
// pitch) are INDEPENDENT, and only their difference drives the splice cadence. Feeding 1:1 via
|
||||
// process() holds duration and moves pitch; feeding faster/slower via writeFrame() /
|
||||
// processNoInput() moves duration at whatever pitch the tap is set to. Nothing here resamples
|
||||
// to preserve duration — the splice/overlap-add IS the pitch-preserving mechanism, which is
|
||||
// what the "WDL_Resampler is not a Preserve engine" invariant asks for.
|
||||
//
|
||||
// Regression history — do not revert any of these:
|
||||
// - Correlated splices, vs. the original two-tap OLA (taps hard-locked w/2 apart, Hann
|
||||
@@ -89,6 +94,13 @@ public:
|
||||
// ratio) so a bad input never runs the tap backward or stalls it.
|
||||
void setShiftRatio(double ratio);
|
||||
|
||||
// Source frames written per output frame — 1.0 unless the caller is stretching. Used ONLY
|
||||
// to size a splice crossfade safely: the outgoing tap closes on the write head at
|
||||
// (ratio - feedRate) per frame, so a fade sized against an assumed 1.0 overruns when the
|
||||
// source is fed slower than the output runs and the tail of the fade reads lapped content.
|
||||
// Values <= 0 are ignored. Exactly 1.0 reproduces the 1:1 geometry bit for bit.
|
||||
void setFeedRate(double rate);
|
||||
|
||||
// Transforms one input frame into one output frame (1 in, 1 out). RT-safe: reads/writes the
|
||||
// pre-sized ring only, no allocation, no lock. Unconfigured returns `in` unchanged. Otherwise
|
||||
// writes `in` at the write head, reads the active tap (crossfading against the outgoing tap
|
||||
@@ -103,6 +115,20 @@ public:
|
||||
// their ring state advances in lockstep. RT-safe: same guarantees as process().
|
||||
AudioSample processLinked(AudioSample in, const SpliceEvent& master);
|
||||
|
||||
// Writes one source frame WITHOUT producing an output frame — the stretch path's surplus
|
||||
// input when the source is consumed faster than the output runs. No splice can fire here:
|
||||
// splices are decided on the read side. No-op while unconfigured or tail-frozen, and it
|
||||
// deliberately leaves lastSplice_ alone so a linked follower's schedule is unaffected.
|
||||
// RT-safe.
|
||||
void writeFrame(AudioSample in);
|
||||
|
||||
// Produces one output frame WITHOUT consuming a source frame — the stretch path's starved
|
||||
// output frame when the source is consumed slower than the output runs. Identical to
|
||||
// process()/processLinked() in every other respect. Returns 0 while unconfigured (there is
|
||||
// no input to pass through). RT-safe.
|
||||
AudioSample processNoInput();
|
||||
AudioSample processNoInputLinked(const SpliceEvent& master);
|
||||
|
||||
const SpliceEvent& lastSplice() const { return lastSplice_; }
|
||||
|
||||
// Call once the source stream is exhausted — no real frame remains to feed process().
|
||||
@@ -137,7 +163,9 @@ private:
|
||||
void applySplice(const SpliceEvent& ev);
|
||||
// Shared body of process()/processLinked(); `linked` null = master mode (own trigger +
|
||||
// search), non-null = follower mode (splice iff linked->fired, with linked's decision).
|
||||
AudioSample processImpl(AudioSample in, const SpliceEvent* linked);
|
||||
// `write` false is the starved stretch frame: read/splice/advance the taps, but consume no
|
||||
// input and hold the write head (the same shape tail-freezing already takes).
|
||||
AudioSample processImpl(AudioSample in, const SpliceEvent* linked, bool write);
|
||||
|
||||
std::vector<AudioSample> ring_; // delay line, length `ringLen_` == 2 * window_
|
||||
std::int64_t window_ = 0; // nominal splice jump in frames; <= 1 = pass-through
|
||||
@@ -161,6 +189,7 @@ private:
|
||||
// clamps its up-jump to this so it never lands in
|
||||
// unwritten silence
|
||||
double ratio_ = 1.0; // current shift ratio (>0)
|
||||
double feedRate_ = 1.0; // source frames written per output frame; splice-fade only
|
||||
SpliceEvent lastSplice_{}; // decision of the most recent process*() frame; cleared
|
||||
// at the top of every frame, set on a splice
|
||||
bool tailFrozen_ = false; // writer frozen (source exhausted); tap recycles the
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
#pragma once
|
||||
// time_stretch — the Preserve engine's TIME half: how fast the source is consumed, given a
|
||||
// playback rate. It pairs with pitch_shift's PITCH half (how fast the ring's read tap runs);
|
||||
// the two rates are independent over one delay ring, and only their difference reaches the
|
||||
// splice machinery. Header-inline: every member sits on the per-voice-per-sample feed.
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
#include "core/instrument/engine/loop/loop_span.h"
|
||||
|
||||
namespace reasampler::instrument::engine {
|
||||
|
||||
// The playback rates the Preserve DSP is measured over, and therefore the only ones it
|
||||
// accepts. The ceiling also bounds a voice's per-output-frame feed loop (kMaxFeedPerFrame
|
||||
// source frames) — the RT-safety argument for feeding a variable count at all.
|
||||
//
|
||||
// This range NARROWS the splice-cadence failure onto the source fundamental; it does not
|
||||
// eliminate it. A splice recurs every `window / |rate - shift|` output frames (the tap's
|
||||
// delay drifts across one window at that per-frame rate); the shifted tone's own period is
|
||||
// `sourcePeriod / shift` output frames. Whenever the recurrence interval is shorter than
|
||||
// that period, a splice lands inside a single perceived cycle and the correlation search
|
||||
// has less than one period to align against. Measured at rate 4.0, shift 0.25 (-24 st):
|
||||
// interval 2205/3.75 ~= 588 vs period ~4*P ~= 785 frames (P ~= 196) — matches the originally
|
||||
// observed 539-vs-785 failure. This range's ceiling (2.0, not 4.0) raises the safe floor, it
|
||||
// does not remove it: at rate 2.0, shift 0.25, interval = 2205/1.75 = 1260 still produces
|
||||
// measurable splice debris for any source period P > 315 frames (~140 Hz at 44.1k) — inside
|
||||
// bass/low-vocal material, and -24 st is reachable from the Pitch knob alone. pitch_shift_tests
|
||||
// (testStretchCadenceCornerArtifactEnergyAtRate2ShiftQuarter) asserts this corner directly at
|
||||
// P=500/600/700: energy outside the fundamental runs 7-21% there against ~0% on an aligned
|
||||
// control at the same rate/shift — zero-crossing period is NOT what it checks, since splice
|
||||
// debris fools that estimator into reading the wrong period on a render whose fundamental is
|
||||
// actually fine. (The pre-stretch rate-1.0 engine's floor by the same inequality is P > 735,
|
||||
// ~60 Hz — what this range raises the floor from, not what it removes.)
|
||||
//
|
||||
// A SECOND, INDEPENDENT limit binds the same material, and no rate bound touches it. A splice
|
||||
// relocates the tap by the nominal window refined by a search over +/- window/4, so the
|
||||
// reachable relocation distances are exactly [0.75, 1.25] * window; a phase-aligned splice
|
||||
// needs a WHOLE NUMBER of source periods inside that one interval. The interval is 0.5*window
|
||||
// wide, so any period <= window/2 always has a multiple in it — but above that, coverage
|
||||
// breaks into disjoint bands (n=1 covers periods [0.75, 1.25]*window, n=2 covers
|
||||
// [0.375, 0.625]*window) and the gap between them is reachable by nothing. Because both the
|
||||
// interval and the period scale with the sample rate, the unalignable set is fixed in Hz by
|
||||
// the window's MILLISECONDS: at 50 ms that is f < 16 Hz and 26.7 Hz < f < 32 Hz. Measured
|
||||
// (Release, 44.1k and 48k) at 30 Hz: the rendered pitch stays correct, but energy outside the
|
||||
// fundamental is 3.6% at +2 st / rate 1.0 and 15.5% at rate 2.0, against 0.00% at 34 Hz under
|
||||
// identical conditions; at 29 Hz / rate 2.0 the tone itself lands 7.4% flat. Unlike the
|
||||
// cadence inequality above, this one is not about how OFTEN a splice fires — a window of at
|
||||
// least two source periods removes it outright, and nothing else does.
|
||||
inline constexpr double kStretchRateMin = 0.5;
|
||||
inline constexpr double kStretchRateMax = 2.0;
|
||||
inline constexpr int kMaxFeedPerFrame = 2; // ceil(kStretchRateMax)
|
||||
|
||||
// Non-positive and NaN fold to unity rather than to the minimum: an unusable rate should leave
|
||||
// playback alone, not silently quarter-speed it (the same stance as setShiftRatio's refusal to
|
||||
// run the tap backward). 1.0 in gives exactly 1.0 out, which is what keeps the unity read
|
||||
// bit-identical.
|
||||
inline double clampStretchRate(double rate) {
|
||||
if (!(rate > 0.0)) return 1.0;
|
||||
if (rate < kStretchRateMin) return kStretchRateMin;
|
||||
return rate > kStretchRateMax ? kStretchRateMax : rate;
|
||||
}
|
||||
|
||||
// One Preserve voice's source-feed schedule: a fractional source cursor answering, per OUTPUT
|
||||
// frame, which whole source frames fall due. At rate 1.0 that is exactly one frame per output
|
||||
// frame with no residue carried — bit for bit the pre-stretch feed.
|
||||
class StretchCursor {
|
||||
public:
|
||||
// `frame` is where the ring prime stopped; the per-frame feed continues there.
|
||||
void start(std::int64_t frame) {
|
||||
frame_ = frame;
|
||||
debt_ = 0.0;
|
||||
}
|
||||
|
||||
// Adds one output frame's worth of source at `rate` and returns how many whole source
|
||||
// frames are now due, in [0, kMaxFeedPerFrame]. Take each of them with next(). The clamp
|
||||
// lives here rather than at the caller because this return value is the loop bound.
|
||||
std::int64_t due(double rate) {
|
||||
debt_ += clampStretchRate(rate);
|
||||
const std::int64_t whole = static_cast<std::int64_t>(debt_); // debt_ >= 0: trunc = floor
|
||||
debt_ -= static_cast<double>(whole);
|
||||
return whole;
|
||||
}
|
||||
|
||||
// The next due source frame, wrapped into the sustain loop, advancing the cursor past it.
|
||||
// Advances even past the playable span — the caller freezes the shifter's writer there, and
|
||||
// a cursor that stalled instead would re-feed one frame forever.
|
||||
std::int64_t next(const loop::ResolvedLoop& lp) {
|
||||
if (lp.active) {
|
||||
while (frame_ >= lp.end) frame_ -= lp.length;
|
||||
}
|
||||
return frame_++;
|
||||
}
|
||||
|
||||
std::int64_t frame() const { return frame_; }
|
||||
|
||||
private:
|
||||
std::int64_t frame_ = 0;
|
||||
double debt_ = 0.0; // fractional source frames carried into the next output frame
|
||||
};
|
||||
|
||||
} // namespace reasampler::instrument::engine
|
||||
@@ -18,7 +18,8 @@ void Voice::presizePreserveShifters(std::int64_t windowFrames) {
|
||||
primeBuf_.assign(windowFrames > 1 ? static_cast<std::size_t>(windowFrames) : 0, 0.0f);
|
||||
}
|
||||
|
||||
void Voice::start(int note, int velocity, const SampleData& sample, bool declickTakeover) {
|
||||
void Voice::start(int note, int velocity, const SampleData& sample, bool declickTakeover,
|
||||
double stretchRate) {
|
||||
// Before any state reset, record the pre-cut reference (last rendered output) and mark
|
||||
// the compensation pending iff this start is a takeover/steal of a sounding voice and the
|
||||
// caller opted in. The ramp is seeded on the first frame rendered after the restart, from
|
||||
@@ -59,6 +60,9 @@ void Voice::start(int note, int velocity, const SampleData& sample, bool declick
|
||||
baseRatio_ = keyTrackedRatio(note, sample.rootNote, sample.keyTrack) * velPitchRatio_;
|
||||
playMode_ = p.playMode;
|
||||
pitchEngine_ = p.pitchEngine;
|
||||
// Clamped once here so the read head's increment and the feed cursor's debt accumulate the
|
||||
// SAME value — they must stay exactly one window apart for the note's whole life.
|
||||
stretchRate_ = instrument::engine::clampStretchRate(stretchRate);
|
||||
|
||||
// Clamp into [0, frames): a start at or past the end degrades to 0 (play from the top)
|
||||
// rather than starting a voice already off the end.
|
||||
@@ -234,7 +238,9 @@ void Voice::start(int note, int velocity, const SampleData& sample, bool declick
|
||||
}
|
||||
// Per-frame feed continues at `p` (the feed bound when the prime exhausted the
|
||||
// playable span).
|
||||
feedPos_ = p;
|
||||
stretch_.start(p);
|
||||
shiftL_.setFeedRate(stretchRate_);
|
||||
shiftR_.setFeedRate(stretchRate_);
|
||||
if (!loopWrap && primeCount < w) {
|
||||
// Sub-window playable span: the source is already exhausted at prime time.
|
||||
shiftL_.freezeTail();
|
||||
@@ -321,6 +327,8 @@ void Voice::retune(int note) {
|
||||
// Filter key-tracking follows the pitch: it is a function of the note, so a slide moves it
|
||||
// too. The velocity offset deliberately stays the first note's, matching velocityGain_.
|
||||
if (filterOn_) updateFilterCutoffBase(note);
|
||||
// stretchRate_ (Preserve's duration control) is untouched here too — it is a note-on latch
|
||||
// like velocityGain_, not a per-note property to re-resolve on a legato slide.
|
||||
}
|
||||
|
||||
void Voice::release() {
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
#include "core/instrument/engine/loop/loop_span.h"
|
||||
#include "core/instrument/engine/pitch_shift.h"
|
||||
#include "core/instrument/engine/play_params.h"
|
||||
#include "core/instrument/engine/time_stretch.h"
|
||||
#include "core/instrument/engine/velocity_curve.h"
|
||||
|
||||
namespace reasampler {
|
||||
@@ -108,7 +109,14 @@ public:
|
||||
// and this voice is currently active (a takeover/steal restart, not a fresh start), arms
|
||||
// the difference-seeded declick compensation on the first frame after the restart (see
|
||||
// kDeclickDecay above). A fresh start never declicks.
|
||||
void start(int note, int velocity, const SampleData& sample, bool declickTakeover = false);
|
||||
//
|
||||
// `stretchRate` is the PRESERVE playback rate — source frames consumed per output frame,
|
||||
// clamped to [kStretchRateMin, kStretchRateMax]. It is a note-on latch by construction (an
|
||||
// argument, not a member set separately) because the loop fold and the contour scale it
|
||||
// composes with are both note-on folds. Varispeed ignores it: there, rate is a factor of the
|
||||
// read increment, not a second rate. 1.0 is the shipped Preserve read, bit for bit.
|
||||
void start(int note, int velocity, const SampleData& sample, bool declickTakeover = false,
|
||||
double stretchRate = 1.0);
|
||||
|
||||
// Mono legato takeover: re-pitch this active voice to `note` without touching the
|
||||
// amplitude envelope, read position, or shifter state — pitch moves, no re-attack. Both
|
||||
@@ -184,9 +192,9 @@ private:
|
||||
// This frame's amplitude in [0,1] from the active envelope. Spline: the drawn contour read
|
||||
// at the normalized position (one cached-segment compare per frame). Gate: AHDSR ticks once
|
||||
// per output frame (envelope time is wall-clock, independent of read rate). Trigger: the AHD
|
||||
// is evaluated at the source offset (readPos - startFrame) so its stages anchor to source
|
||||
// frames regardless of pitch engine. Sets amplitudeDone_ on finish so advanceFrame frees
|
||||
// the voice.
|
||||
// is evaluated at the source offset (readPos - startFrame) — see the `ratio_ = stretchRate_`
|
||||
// note below for what that means for Preserve's stage-time/rate coupling. Sets
|
||||
// amplitudeDone_ on finish so advanceFrame frees the voice.
|
||||
double tickAmplitude() {
|
||||
double amp;
|
||||
// playMode_ is Trigger whenever a spline is genuinely reachable (resolvePlay forces it —
|
||||
@@ -441,56 +449,78 @@ private:
|
||||
// and the amp envelope shapes the filtered result (drive included).
|
||||
double outL, outRlocal = 0.0;
|
||||
if (pitchEngine_ == PitchEngine::Preserve && shiftL_.configured()) {
|
||||
// Feed the shifters the source stream at unity rate (duration held) and transpose
|
||||
// the output by 2^((note-root + pitchEnvSemis)/12) — pitch envelope adds to the
|
||||
// shift amount, not the read rate. The feed runs one window ahead of readPos_ (the
|
||||
// rings were primed with that window at start()), under the same sustain-loop wrap
|
||||
// rule, reading integer source frames (nothing to interpolate). Past the last real
|
||||
// frame the shifter's writer is frozen — it recycles the real tail it already holds.
|
||||
if (loop.active) {
|
||||
while (feedPos_ >= loop.end) feedPos_ -= loop.length;
|
||||
}
|
||||
// feedPos_ runs one window ahead of readPos_; the last real source frame is
|
||||
// playEnd_-1 for Trigger or frameCount-1 for Gate. Once feedPos_ reaches that bound
|
||||
// the source is exhausted — feeding the held last sample instead would give the
|
||||
// splice correlation a DC plateau it can't align on (periodic troughs at the splice
|
||||
// cadence, growing toward the note end). Freezing the shifter's writer means no
|
||||
// padding ever enters the ring, so the splice machinery keeps recycling the frozen
|
||||
// all-real tail — a continuous tone through the voice's own end. The sustain-loop
|
||||
// path never gets here: the wrap above keeps feedPos_ < loop.end forever.
|
||||
// The two rates the shifter takes (pitch_shift.h owns why they are independent):
|
||||
// the source is FED at stretchRate_, and the tap is SHIFTED by
|
||||
// 2^((note-root + pitchEnvSemis)/12) — the pitch envelope adds to the shift amount,
|
||||
// never to the read rate. The feed runs one window ahead of readPos_ (the rings were
|
||||
// primed with that window at start()), under the same sustain-loop wrap rule,
|
||||
// reading integer source frames into the ring — no RATE-DEPENDENT interpolation
|
||||
// (unlike Varispeed's readPos_ below). The shifter's own read tap still carries a
|
||||
// splice's sub-sample `frac` (pitch_shift.cpp), so it interpolates on every read,
|
||||
// splice or no; that constant fractional delay is not a rate coupling.
|
||||
const bool stereoOut = stereo && haveR && shiftR_.configured();
|
||||
// The last real source frame is playEnd_-1 for Trigger or frameCount-1 for Gate.
|
||||
// Once the feed reaches that bound the source is exhausted — feeding the held last
|
||||
// sample instead would give the splice correlation a DC plateau it can't align on
|
||||
// (periodic troughs at the splice cadence, growing toward the note end). Freezing the
|
||||
// shifter's writer means no padding ever enters the ring, so the splice machinery
|
||||
// keeps recycling the frozen all-real tail — a continuous tone through the voice's
|
||||
// own end. The sustain-loop path never gets here: the wrap keeps the cursor inside
|
||||
// the loop forever.
|
||||
const std::int64_t feedBound =
|
||||
(playMode_ == PlayMode::Trigger && playEnd_ > 0 && playEnd_ < frameCount)
|
||||
? playEnd_ : frameCount;
|
||||
const bool exhausted = feedPos_ >= feedBound;
|
||||
if (exhausted) shiftL_.freezeTail(); // idempotent; input ignored while frozen
|
||||
const bool feedOk = (!exhausted && feedPos_ >= 0 && feedPos_ < frameCount);
|
||||
// Crossfaded on the way IN to the shifter, not on the way out: loop the source,
|
||||
// shift the output.
|
||||
const double feedXw = crossfadeWeight(loop, static_cast<double>(feedPos_));
|
||||
const AudioSample feedL =
|
||||
feedOk ? crossfadedSource(pcm, loop, feedPos_, feedXw) : 0.0f;
|
||||
const double shift = baseRatio_ * envFactor;
|
||||
shiftL_.setShiftRatio(shift);
|
||||
const double shiftedL = static_cast<double>(shiftL_.process(feedL));
|
||||
if (stereoOut) shiftR_.setShiftRatio(shift);
|
||||
|
||||
// 0..kMaxFeedPerFrame source frames fall due this output frame. All but the LAST are
|
||||
// written without producing output; the last rides the ordinary 1-in-1-out
|
||||
// process(), so a rate of exactly 1.0 walks the pre-stretch code path unchanged.
|
||||
// Crossfaded on the way IN to the shifter, not on the way out: loop the source,
|
||||
// shift the output.
|
||||
const std::int64_t due = stretch_.due(stretchRate_);
|
||||
AudioSample feedL = 0.0f, feedR = 0.0f;
|
||||
bool fed = false;
|
||||
for (std::int64_t k = 0; k < due; ++k) {
|
||||
if (fed) { // an earlier frame of this batch: write-only, no output
|
||||
shiftL_.writeFrame(feedL);
|
||||
if (stereoOut) shiftR_.writeFrame(feedR);
|
||||
}
|
||||
const std::int64_t q = stretch_.next(loop);
|
||||
if (q >= feedBound) {
|
||||
shiftL_.freezeTail(); // idempotent; input ignored while frozen
|
||||
if (stereoOut) shiftR_.freezeTail();
|
||||
feedL = feedR = 0.0f;
|
||||
} else {
|
||||
const double xw = crossfadeWeight(loop, static_cast<double>(q));
|
||||
feedL = crossfadedSource(pcm, loop, q, xw);
|
||||
if (stereoOut) feedR = crossfadedSource(pcmR, loop, q, xw);
|
||||
}
|
||||
fed = true;
|
||||
}
|
||||
const double shiftedL =
|
||||
fed ? static_cast<double>(shiftL_.process(feedL))
|
||||
: static_cast<double>(shiftL_.processNoInput());
|
||||
outL = shiftedL;
|
||||
if (stereo) {
|
||||
if (haveR && shiftR_.configured()) {
|
||||
if (stereoOut) {
|
||||
// Genuine stereo (linked lag): channel 1's shifter FOLLOWS channel 0's
|
||||
// splice decisions via processLinked — one correlation search, one lag, one
|
||||
// splice schedule for both channels (standard stereo SOLA). An independent
|
||||
// per-channel search re-drew an inter-channel offset of up to +/-maxLag at
|
||||
// every splice: stereo image wander at the splice cadence + mono-sum
|
||||
// combing. Each shifter is still processed EXACTLY ONCE per output frame
|
||||
// (never twice — that would advance its heads twice and corrupt the state).
|
||||
// (never twice — that would advance its heads twice and corrupt the state);
|
||||
// the batch's earlier frames go through writeFrame, which produces none.
|
||||
// Gated on haveR so a MONO sample never touches shiftR_ — start() only
|
||||
// primes it for genuinely stereo samples, and a stale un-primed ring must
|
||||
// not leak a previous note.
|
||||
if (exhausted) shiftR_.freezeTail();
|
||||
const AudioSample feedR =
|
||||
feedOk ? crossfadedSource(pcmR, loop, feedPos_, feedXw) : 0.0f;
|
||||
shiftR_.setShiftRatio(shift);
|
||||
outRlocal =
|
||||
static_cast<double>(shiftR_.processLinked(feedR, shiftL_.lastSplice()));
|
||||
fed ? static_cast<double>(
|
||||
shiftR_.processLinked(feedR, shiftL_.lastSplice()))
|
||||
: static_cast<double>(
|
||||
shiftR_.processNoInputLinked(shiftL_.lastSplice()));
|
||||
} else {
|
||||
// Mono sample in stereo mode (dual-mono): shiftL_ already produced the
|
||||
// shifted value from the mono feed; mirror it to R. Do NOT call
|
||||
@@ -498,9 +528,20 @@ private:
|
||||
outRlocal = shiftedL;
|
||||
}
|
||||
}
|
||||
++feedPos_;
|
||||
// Preserve advances the read head at the SOURCE rate (duration preserved).
|
||||
ratio_ = 1.0;
|
||||
// Preserve advances the read head at the STRETCH rate — the one duration control.
|
||||
// Everything downstream of it (the loop wrap, the Trigger span, the spline phase)
|
||||
// therefore stays a source-frame fact and scales by construction.
|
||||
//
|
||||
// Consequence (§2.4 of instrument-control-surface.md is explicit that staged
|
||||
// envelopes' stage times are wall-clock and do NOT scale with rate): Trigger's amp
|
||||
// AHD and filter AHD are both evaluated at sourceOffset() = readPos_ - startFrame_
|
||||
// (tickAmplitude/tickFilterCutoff above), which now advances at stretchRate_ instead
|
||||
// of always 1.0 — so those two envelopes will scale with a future non-unity Rate.
|
||||
// This is NEW here: Preserve's ratio_ was pinned at 1.0 before this track, so those
|
||||
// stage times were exact wall-clock. It is latent (nothing publishes a non-unity
|
||||
// rate yet) and owned by the track that adds the Rate control, not this one — Gate's
|
||||
// AHDSR (env_.tick(), per-output-frame) and every spline contour are unaffected.
|
||||
ratio_ = stretchRate_;
|
||||
} else {
|
||||
// VARISPEED: pitch and duration coupled. The read rate carries the repitch; the
|
||||
// pitch envelope multiplies the ratio for the read-rate bias (unchanged idiom when
|
||||
@@ -676,9 +717,10 @@ private:
|
||||
//
|
||||
// The shifter rings are primed at start() with the first window of the actual upcoming
|
||||
// source (silence past the end) — output frame 0 is source frame `start`, no ring-fill
|
||||
// silence, and splices always land in real history. feedPos_ is the integer source frame
|
||||
// fed to the shifters next; it runs exactly one window ahead of readPos_ under the same
|
||||
// sustain-loop wrap rule. Once feedPos_ passes the last real frame (Gate: sample end;
|
||||
// silence, and splices always land in real history. stretch_ is the integer source frame
|
||||
// fed to the shifters next plus the fractional rate debt; it runs one window ahead of
|
||||
// readPos_ under the same sustain-loop wrap rule and at the same rate, so the two stay one
|
||||
// window apart at every stretch. Once it passes the last real frame (Gate: sample end;
|
||||
// Trigger: playEnd_), the shifters' writers freeze — no padding enters the rings and the
|
||||
// splice machinery recycles the frozen real tail through the note end (see advanceFrame).
|
||||
// primeBuf_ is the presized scratch the prime stream is assembled into.
|
||||
@@ -686,7 +728,8 @@ private:
|
||||
PitchEnvelope pitchEnv_;
|
||||
PitchShifter shiftL_;
|
||||
PitchShifter shiftR_;
|
||||
std::int64_t feedPos_ = 0;
|
||||
instrument::engine::StretchCursor stretch_;
|
||||
double stretchRate_ = 1.0; // Preserve playback rate, clamped and latched at note-on
|
||||
std::vector<AudioSample> primeBuf_;
|
||||
|
||||
// lastOut{L,R}_ track the voice's most recent rendered output. A takeover/steal start()
|
||||
|
||||
Reference in New Issue
Block a user