diff --git a/src/core/instrument/CLAUDE.md b/src/core/instrument/CLAUDE.md index 061e08f..9d54898 100644 --- a/src/core/instrument/CLAUDE.md +++ b/src/core/instrument/CLAUDE.md @@ -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. diff --git a/src/core/instrument/engine/CMakeLists.txt b/src/core/instrument/engine/CMakeLists.txt index af6bb07..864b9b9 100644 --- a/src/core/instrument/engine/CMakeLists.txt +++ b/src/core/instrument/engine/CMakeLists.txt @@ -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) diff --git a/src/core/instrument/engine/pitch_shift.cpp b/src/core/instrument/engine/pitch_shift.cpp index 8eea981..39db550 100644 --- a/src/core/instrument/engine/pitch_shift.cpp +++ b/src/core/instrument/engine/pitch_shift.cpp @@ -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(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(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(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; } diff --git a/src/core/instrument/engine/pitch_shift.h b/src/core/instrument/engine/pitch_shift.h index ea8fc50..832a181 100644 --- a/src/core/instrument/engine/pitch_shift.h +++ b/src/core/instrument/engine/pitch_shift.h @@ -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 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 diff --git a/src/core/instrument/engine/time_stretch.h b/src/core/instrument/engine/time_stretch.h new file mode 100644 index 0000000..c661dae --- /dev/null +++ b/src/core/instrument/engine/time_stretch.h @@ -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 + +#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(debt_); // debt_ >= 0: trunc = floor + debt_ -= static_cast(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 diff --git a/src/core/instrument/engine/voice.cpp b/src/core/instrument/engine/voice.cpp index b64fd72..5badbc4 100644 --- a/src/core/instrument/engine/voice.cpp +++ b/src/core/instrument/engine/voice.cpp @@ -18,7 +18,8 @@ void Voice::presizePreserveShifters(std::int64_t windowFrames) { primeBuf_.assign(windowFrames > 1 ? static_cast(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() { diff --git a/src/core/instrument/engine/voice.h b/src/core/instrument/engine/voice.h index 3c1f1eb..1349f9d 100644 --- a/src/core/instrument/engine/voice.h +++ b/src/core/instrument/engine/voice.h @@ -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(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(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(q)); + feedL = crossfadedSource(pcm, loop, q, xw); + if (stereoOut) feedR = crossfadedSource(pcmR, loop, q, xw); + } + fed = true; + } + const double shiftedL = + fed ? static_cast(shiftL_.process(feedL)) + : static_cast(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(shiftR_.processLinked(feedR, shiftL_.lastSplice())); + fed ? static_cast( + shiftR_.processLinked(feedR, shiftL_.lastSplice())) + : static_cast( + 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 primeBuf_; // lastOut{L,R}_ track the voice's most recent rendered output. A takeover/steal start() diff --git a/tests/energy_outside_fundamental.h b/tests/energy_outside_fundamental.h new file mode 100644 index 0000000..b71ba97 --- /dev/null +++ b/tests/energy_outside_fundamental.h @@ -0,0 +1,50 @@ +#pragma once +// Out-of-band spectral energy metric: the same period-grid, Hann-windowed direct-evaluation +// approach as test_preserve_low_frequency.cpp's reportSpectrum. Chosen over zero-crossing +// counting because splice debris adds spurious crossings that make that estimator +// anti-correlated with severity (a render can read a badly wrong PERIOD while this metric +// shows it is mostly clean, or vice versa). Grid/segment sizes are smaller than the hand-run +// harness's — this one runs inside the gated suite. + +#include +#include +#include + +namespace reasampler::test_support { + +// Percentage (0..100) of the segment [from, from+len)'s spectral energy that falls outside +// +/- 6% of `wantPeriod` (frames). 0 = a clean single tone at that period; higher values mean +// harmonics, splice-cadence sidebands, or crossfade cancellation debris are present. +inline double energyOutsideFundamentalPercent(const std::vector& v, std::size_t from, + std::size_t len, double wantPeriod) { + constexpr double kPi = 3.14159265358979323846; + constexpr int kGrid = 400; + const double pLo = 30.0, pHi = 8000.0; + std::vector mag(static_cast(kGrid)); + std::vector per(static_cast(kGrid)); + for (int g = 0; g < kGrid; ++g) { + // Geometric grid: constant relative resolution across the swept period range. + const double p = pLo * std::pow(pHi / pLo, static_cast(g) / (kGrid - 1)); + per[static_cast(g)] = p; + double re = 0.0, im = 0.0; + const double w = 2.0 * kPi / p; + for (std::size_t k = 0; k < len && from + k < v.size(); ++k) { + const double hann = 0.5 * (1.0 - std::cos(2.0 * kPi * static_cast(k) / + static_cast(len))); + const double x = v[from + k] * hann; + re += x * std::cos(w * static_cast(k)); + im += x * std::sin(w * static_cast(k)); + } + mag[static_cast(g)] = std::sqrt(re * re + im * im); + } + double eTotal = 0.0, eFund = 0.0; + for (int g = 0; g < kGrid; ++g) { + const std::size_t i = static_cast(g); + const double e = mag[i] * mag[i]; + eTotal += e; + if (std::fabs(per[i] - wantPeriod) / wantPeriod < 0.06) eFund += e; + } + return eTotal > 0.0 ? 100.0 * (1.0 - eFund / eTotal) : 0.0; +} + +} // namespace reasampler::test_support diff --git a/tests/test_pitch_shift.cpp b/tests/test_pitch_shift.cpp index 768e45e..c63cf6c 100644 --- a/tests/test_pitch_shift.cpp +++ b/tests/test_pitch_shift.cpp @@ -23,11 +23,16 @@ // 6. unity + latency contract — asserted bit-exactly: a warm()ed shifter at ratio 1.0 IS a // clean window delay; a prime()d one has ZERO added latency (out[i] == src[i] to the // bit) — the GA2 immediate-onset claim. +// 9. time-stretch — the write rate (duration) and the tap rate (pitch) are independent: a +// source fed faster/slower than the output runs moves along the output timeline with its +// pitch untouched, composes with the full transposition range, and never resamples to do +// it. A resampled read is the explicit non-tautology witness in the duration test. // 8. stereo linked lag (Q-W0 T1-01) — a follower channel driven via processLinked() mirrors // the master's splice decision (jump/lag/frac/fadeLen AND firing frame) exactly, on // decorrelated stereo content where an independent per-channel search provably diverges. #include "../src/core/instrument/engine/pitch_shift.h" +#include "energy_outside_fundamental.h" #include #include @@ -581,6 +586,234 @@ static void testStereoLinkedLagSharedSchedule() { CHECK(!mirrorDiverged); // applySplice() reproduces splice() bit-identically } +// --- 9. Time-stretch: the WRITE rate is duration, the TAP rate is pitch, and they are +// independent. Feeding faster/slower than the output runs moves the content along the +// output timeline WITHOUT moving its pitch — no resampling anywhere, which is what +// "WDL_Resampler is not a Preserve engine" asks for. --- + +// Drives the shifter with a fractional feed rate the way the Voice does: all but the last +// source frame due on an output frame go through writeFrame (no output), the last through +// process(); an output frame with none due takes processNoInput(). Returns the output plus, +// via `consumed`, how much source it ate. +static std::vector runStretch(const std::vector& src, std::int64_t w, + double feedRate, double shift, std::size_t outFrames, + std::size_t* consumed) { + PitchShifter ps; + ps.configure(w); + ps.prime(src.data(), w); + ps.setShiftRatio(shift); + ps.setFeedRate(feedRate); + std::size_t pos = static_cast(w); + double debt = 0.0; + std::vector out(outFrames); + for (std::size_t i = 0; i < outFrames; ++i) { + debt += feedRate; + const int due = static_cast(debt); + debt -= static_cast(due); + AudioSample last = 0.0f; + bool fed = false; + for (int k = 0; k < due; ++k) { + if (fed) ps.writeFrame(last); + last = pos < src.size() ? src[pos] : 0.0f; + ++pos; + fed = true; + } + out[i] = static_cast(fed ? ps.process(last) : ps.processNoInput()); + } + if (consumed != nullptr) *consumed = pos - static_cast(w); + return out; +} + +// Mean spacing between positive-going zero crossings over [from, to). +static double periodIn(const std::vector& v, std::size_t from, std::size_t to) { + double sum = 0.0; + std::size_t prev = 0, count = 0; + for (std::size_t i = from + 1; i < to; ++i) { + if (v[i - 1] <= 0.0 && v[i] > 0.0) { + if (count > 0) sum += static_cast(i - prev); + prev = i; + ++count; + } + } + return count > 1 ? sum / static_cast(count - 1) : 0.0; +} + +static void testStretchMovesDurationNotPitch() { + // A source that changes pitch ONCE, at a known source frame: period 200 before it, period + // 100 after. Where that change lands in the OUTPUT is duration; what the two periods + // measure is pitch. A stretcher moves the first and not the second; a resampled read moves + // both, which is exactly the distinction under test. + const std::int64_t w = 2205; + const std::size_t change = 40000; // source frame where the period halves + const std::size_t srcLen = 160000; + std::vector src(srcLen); + double phase = 0.0; + for (std::size_t i = 0; i < srcLen; ++i) { + phase += 2.0 * kPi / (i < change ? 200.0 : 100.0); + src[i] = static_cast(std::sin(phase)); + } + for (double rate : {0.5, 1.0, 2.0}) { + // Duration: the source is consumed at the feed rate, so the change lands at + // change/rate in the output — the run is sized to reach past it at every rate. + const std::size_t changeOut = static_cast(change / rate); + const std::size_t outFrames = changeOut + 12000; + std::size_t consumed = 0; + const std::vector out = + runStretch(src, w, rate, /*shift=*/1.0, outFrames, &consumed); + // Pitch: measured well clear of the transition on both sides, and UNCHANGED by the + // rate — 200 before, 100 after, at 0.5x, 1x and 2x alike. + const double before = periodIn(out, changeOut / 4, changeOut / 4 + 6000); + const double after = periodIn(out, changeOut + 2000, changeOut + 8000); + CHECK(approx(before, 200.0, 10.0)); + CHECK(approx(after, 100.0, 5.0)); + // Non-tautology witness: a RESAMPLED read of the same source at the same rate would + // have produced 200/rate and 100/rate here. At rate != 1 those differ from the + // measurements above by far more than the tolerances, so the assertions genuinely + // separate a stretch from a resample. + if (rate != 1.0) { + CHECK(std::fabs(before - 200.0 / rate) > 20.0); + CHECK(std::fabs(after - 100.0 / rate) > 20.0); + } + // ...and the source really was consumed at the rate (the duration half of the claim). + CHECK(approx(static_cast(consumed), + static_cast(outFrames) * rate, 2.0)); + // No dead stretches anywhere, frame 0 included: the stretch path must not reintroduce + // the onset gap prime() exists to close. + std::size_t worstGap = 0, run = 0; + for (std::size_t i = 0; i < outFrames; ++i) { + if (std::fabs(out[i]) < 1e-3) { + ++run; + if (run > worstGap) worstGap = run; + } else { + run = 0; + } + } + CHECK(worstGap < 32); + } +} + +// The stretch and the transposition compose over the SAME ring, and a feed rate the shift does +// not match is where the splice fade's headroom is tightest (the outgoing tap closes on the +// writer at ratio - feedRate, which setFeedRate exists to tell it). Bounded, finite, gap-free +// across the corners of the engine's rate range crossed with the full transposition range. +static void testStretchAndShiftComposeSafely() { + const std::int64_t w = 2205; + const double f0 = 1.0 / 196.37; // the adversarial non-integer period + const std::size_t srcLen = 400000; + std::vector src(srcLen); + for (std::size_t i = 0; i < srcLen; ++i) { + src[i] = static_cast(std::sin(2.0 * kPi * f0 * static_cast(i))); + } + for (double rate : {0.5, 0.75, 1.0, 1.5, 2.0}) { + for (double semis : {-24.0, -12.0, -5.0, 0.0, 7.0, 12.0, 24.0}) { + const double shift = std::pow(2.0, semis / 12.0); + const std::size_t outFrames = 60000; + const std::vector out = + runStretch(src, w, rate, shift, outFrames, nullptr); + std::size_t worstGap = 0, run = 0; + double peak = 0.0; + for (std::size_t i = 0; i < outFrames; ++i) { + CHECK(std::isfinite(out[i])); + const double a = std::fabs(out[i]); + if (a > peak) peak = a; + if (a < 1e-3) { + ++run; + if (run > worstGap) worstGap = run; + } else { + run = 0; + } + } + CHECK(worstGap < 32); // continuous: every splice landed in real, aligned history + CHECK(peak < 1.2); // complementary fades: no cancellation, no bulge + CHECK(peak > 0.8); // ...and it played at full level + // Pitch is the TAP's, not the feed's: the observed period is the source period + // divided by the shift, whatever the rate. + const double p = periodIn(out, 20000, 50000); + if (!approx(p, 196.37 / shift, 196.37 / shift * 0.12)) { + std::printf(" rate %.2f semis %.0f: period %.2f want %.2f\n", rate, semis, p, + 196.37 / shift); + } + CHECK(approx(p, 196.37 / shift, 196.37 / shift * 0.12)); + } + } +} + +// The [0.5, 2.0] rate bound (time_stretch.h) narrows the splice-cadence failure onto the +// source fundamental rather than eliminating it. At rate 2.0, shift 0.25 (-24 st) — both +// inside the shipped range — the header's own derivation puts the safe-source floor at a +// period of 315 frames (~140 Hz @ 44.1k): P=500/600/700 sit above that floor, on purpose, +// asserting the corner rather than assuming it. Zero-crossing period is NOT the right +// observable here: an investigation (test_preserve_low_frequency.cpp) found the P=500 +// render's FUNDAMENTAL within 0.03% of target by autocorrelation and spectral peak alike, +// while the zero-crossing estimator read 23% flat — splice debris adds spurious crossings +// the count cannot tell from a real detune. Energy outside the fundamental tracks the actual +// damage instead: measured here (same rate/shift/source, this file's own metric parameters) +// at 7.31% / 14.41% / 21.22% for P=500/600/700, against 0.10% on an alignable control (P=200, +// below the safe floor) at the same rate and shift — so that is what this asserts: a known, +// characterised property of the range, not a pass/fail on a period estimate. A failure on +// either bound below is a finding — report it, don't retune the thresholds to hide it. +static void testStretchCadenceCornerArtifactEnergyAtRate2ShiftQuarter() { + using reasampler::test_support::energyOutsideFundamentalPercent; + const std::int64_t w = 2205; + const double rate = 2.0; + const double shift = std::pow(2.0, -24.0 / 12.0); // 0.25 + const std::size_t outFrames = 60000; + const std::size_t from = 20000, len = 32768; + + // Below the safe floor (P > 315 frames): the cadence inequality predicts real damage, + // measured at 7-21% (see above). The threshold (5%) sits above the alignable control's + // near-zero floor and under the observed range, so it discriminates a genuine cadence hit + // from a clean render; the ceiling (30%) is a generous margin above the highest measured + // value, there to catch a much worse regression rather than to chase today's exact number. + for (double period : {500.0, 600.0, 700.0}) { + const double f0 = 1.0 / period; + const std::size_t srcLen = 400000; + std::vector src(srcLen); + for (std::size_t i = 0; i < srcLen; ++i) { + src[i] = static_cast(std::sin(2.0 * kPi * f0 * static_cast(i))); + } + const std::vector out = runStretch(src, w, rate, shift, outFrames, nullptr); + for (double v : out) CHECK(std::isfinite(v)); + const double want = period / shift; + const double energyPct = energyOutsideFundamentalPercent(out, from, len, want); + std::printf(" [cadence corner] period %.0f (rate 2.0, -24 st): energy outside " + "fundamental %.2f%% (want period %.1f fr)\n", period, energyPct, want); + CHECK(energyPct > 5.0); + CHECK(energyPct < 30.0); + } + + // The alignable control: same rate/shift, a source period (200 < 315) the cadence + // inequality does not reach. Without this, a future change that raised the noise floor + // EVERYWHERE (not just at this corner) would still read "under 30%" above and slide + // through — this is what catches that case. + { + const double period = 200.0; + const double f0 = 1.0 / period; + const std::size_t srcLen = 400000; + std::vector src(srcLen); + for (std::size_t i = 0; i < srcLen; ++i) { + src[i] = static_cast(std::sin(2.0 * kPi * f0 * static_cast(i))); + } + const std::vector out = runStretch(src, w, rate, shift, outFrames, nullptr); + for (double v : out) CHECK(std::isfinite(v)); + const double want = period / shift; + const double energyPct = energyOutsideFundamentalPercent(out, from, len, want); + std::printf(" [alignable control] period %.0f (rate 2.0, -24 st): energy outside " + "fundamental %.2f%% (want period %.1f fr)\n", period, energyPct, want); + CHECK(energyPct < 5.0); + } +} + +// The two new entry points on a shifter that was never configured (a Varispeed voice's) — +// neither may touch the empty ring. +static void testStretchEntryPointsOnPassThrough() { + PitchShifter ps; + CHECK(!ps.configured()); + ps.writeFrame(0.5f); // no ring to write into + CHECK(ps.processNoInput() == 0.0f); // no input to pass through + CHECK(ps.process(0.25f) == 0.25f); // and the 1:1 path still passes through +} + int main() { testDurationInvariance(); testUnityRoughlyReproduces(); @@ -590,6 +823,10 @@ int main() { testUnityBitExactAndLatency(); testFreezeTailContinuousTone(); testStereoLinkedLagSharedSchedule(); + testStretchMovesDurationNotPitch(); + testStretchAndShiftComposeSafely(); + testStretchCadenceCornerArtifactEnergyAtRate2ShiftQuarter(); + testStretchEntryPointsOnPassThrough(); if (g_fail == 0) { std::printf("all pitch_shift tests passed\n"); diff --git a/tests/test_preserve_low_frequency.cpp b/tests/test_preserve_low_frequency.cpp new file mode 100644 index 0000000..7c02540 --- /dev/null +++ b/tests/test_preserve_low_frequency.cpp @@ -0,0 +1,700 @@ +// Measurement harness for the Preserve engine's behaviour on LOW-FREQUENCY material. +// Reports numbers; it renders no perceptual verdict and changes no DSP. +// +// The question it answers: a splice relocates the read tap by a nominal `window` refined by a +// correlation search over +/- maxLag, so the reachable relocation distances form ONE bounded +// interval. Phase-aligning a splice needs a WHOLE NUMBER OF SOURCE PERIODS inside that +// interval, and for some periods none exists — a geometric limit, separate from the +// splice-CADENCE inequality. Both now sit in time_stretch.h; this is what measured them. +// +// Two findings shaped the sections below and are worth knowing before reading the output: +// whether an unreachable multiple accumulates into a DETUNE or only wobbles the phase depends +// on whether the nearest multiple misses on one side or straddles (a straddle cancels in the +// mean); and PITCH IS THE WRONG THING TO MEASURE HERE — the fundamental usually survives, so +// the load-bearing metric is energy outside it. Zero-crossing counting in particular reports a +// wrong period on renders whose fundamental is provably correct, which is section C. +// +// Measures, at both 44.1k and 48k geometry: +// A. the reachable relocation interval, observed rather than derived (jump/lag/frac off +// every SpliceEvent), and the alignment-reachability predicate over frequency. +// B. Voice-level renders at 30 Hz: the shipped default path, then transposition at rate 1.0 +// and rate 0.5/2.0 with none, then a 20-200 Hz sweep to locate the turnover. +// C. the P=500-frame (~88 Hz) case the pitch_shift floor probe fails on, measured with three +// independent pitch estimators to separate the two mechanisms from a measurement artifact. +// D. a window sweep at 30 Hz — what a larger window would buy, and what it would cost. +// E. alignable frequencies under identical conditions, without which D and B have no scale. + +#include "../src/core/instrument/engine/pitch_shift.h" +#include "../src/core/instrument/engine/time_stretch.h" +#include "../src/core/instrument/engine/voice.h" + +#include +#include +#include +#include +#include +#include + +using namespace reasampler; +using namespace reasampler::instrument::engine; + +static int g_fail = 0; +#define CHECK(cond) do { if(!(cond)) { \ + std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) + +constexpr double kPi = 3.14159265358979323846; + +// --------------------------------------------------------------------------------------- +// Source + render helpers +// --------------------------------------------------------------------------------------- + +// A pure sine at `freqHz`, phase-continuous, long enough that a rate-2.0 render never +// exhausts it (the caller sizes `frames`). +static SampleData sineSample(double freqHz, int sampleRate, std::size_t frames, + PitchEngine engine, double phase = 0.0) { + SampleData s; + s.frames.resize(frames); + const double w = 2.0 * kPi * freqHz / static_cast(sampleRate); + for (std::size_t i = 0; i < frames; ++i) { + s.frames[i] = static_cast(std::sin(w * static_cast(i) + phase)); + } + s.sampleRate = sampleRate; + s.rootNote = 60; + s.play.pitchEngine = engine; // Gate, no loop, default (fully open) AHDSR + return s; +} + +// One note through the REAL Voice: presize (off-thread step), start with the stretch rate, +// then pull `outFrames` mono frames. `note - 60` is the transposition in semitones. +static std::vector renderVoice(const SampleData& s, int note, double stretchRate, + std::int64_t window, std::size_t outFrames) { + Voice v; + v.presizePreserveShifters(window); + v.start(note, 127, s, /*declickTakeover=*/false, stretchRate); + std::vector out(outFrames, 0.0); + for (std::size_t i = 0; i < outFrames; ++i) { + out[i] = static_cast(v.renderFrame()); + } + return out; +} + +// --------------------------------------------------------------------------------------- +// Metrics +// --------------------------------------------------------------------------------------- + +// Mean spacing between positive-going zero crossings over [from, to) — the same estimator +// test_pitch_shift.cpp uses, kept identical so the two files' numbers are comparable. +static double periodIn(const std::vector& v, std::size_t from, std::size_t to) { + double sum = 0.0; + std::size_t prev = 0, count = 0; + for (std::size_t i = from + 1; i < to && i < v.size(); ++i) { + if (v[i - 1] <= 0.0 && v[i] > 0.0) { + if (count > 0) sum += static_cast(i - prev); + prev = i; + ++count; + } + } + return count > 1 ? sum / static_cast(count - 1) : 0.0; +} + +// Least-squares fit of a single tone at `cyclesPerFrame` over [from, from+len): returns the +// fitted phase and writes the residual energy fraction (1 - explained), which is the +// single-tone-purity metric — 0 = a perfect sine at that frequency, 1 = none of the energy +// is there. Robust to amplitude but NOT to phase drift within the block, which is why the +// caller keeps blocks near one period. +static double toneFit(const std::vector& v, std::size_t from, std::size_t len, + double cyclesPerFrame, double* residFraction) { + double sc = 0.0, ss = 0.0, cc = 0.0, s2 = 0.0, cs = 0.0, e = 0.0; + for (std::size_t k = 0; k < len && from + k < v.size(); ++k) { + const double t = 2.0 * kPi * cyclesPerFrame * static_cast(k); + const double c = std::cos(t), s = std::sin(t); + const double x = v[from + k]; + sc += x * c; ss += x * s; cc += c * c; s2 += s * s; cs += c * s; e += x * x; + } + const double det = cc * s2 - cs * cs; + double a = 0.0, b = 0.0; + if (std::fabs(det) > 1e-12) { + a = (sc * s2 - ss * cs) / det; + b = (ss * cc - sc * cs) / det; + } + const double explained = a * sc + b * ss; // energy captured by the fit + if (residFraction != nullptr) *residFraction = e > 0.0 ? 1.0 - explained / e : 0.0; + return std::atan2(b, a); +} + +// Total unwrapped phase drift (in CYCLES) of the render relative to an ideal tone at +// `cyclesPerFrame`, measured across [from, to) in one-period blocks. This is the direct +// observable behind "the rendered pitch is wrong": a nonzero drift IS a frequency error. +static double phaseDriftCycles(const std::vector& v, std::size_t from, std::size_t to, + double cyclesPerFrame, double* worstStepCycles) { + const std::size_t blk = static_cast(1.0 / cyclesPerFrame); + double total = 0.0, prev = 0.0, worst = 0.0; + bool first = true; + for (std::size_t p = from; p + blk <= to && p + blk < v.size(); p += blk) { + const double ph = toneFit(v, p, blk, cyclesPerFrame, nullptr); + if (!first) { + double d = ph - prev; + while (d > kPi) d -= 2.0 * kPi; + while (d < -kPi) d += 2.0 * kPi; + total += d / (2.0 * kPi); + if (std::fabs(d) / (2.0 * kPi) > worst) worst = std::fabs(d) / (2.0 * kPi); + } + prev = ph; + first = false; + } + if (worstStepCycles != nullptr) *worstStepCycles = worst; + return total; +} + +// Median single-tone residual fraction over the render, in one-period blocks. +static double medianResidual(const std::vector& v, std::size_t from, std::size_t to, + double cyclesPerFrame) { + const std::size_t blk = static_cast(1.0 / cyclesPerFrame); + std::vector r; + for (std::size_t p = from; p + blk <= to && p + blk < v.size(); p += blk) { + double resid = 0.0; + toneFit(v, p, blk, cyclesPerFrame, &resid); + r.push_back(resid); + } + if (r.empty()) return 0.0; + std::size_t mid = r.size() / 2; + std::nth_element(r.begin(), r.begin() + static_cast(mid), r.end()); + return r[mid]; +} + +// Period of the highest normalized-autocorrelation peak over [minLag, maxLag] — a pitch +// estimator that, unlike zero-crossing counting, is not fooled by a low-level fast component +// adding spurious crossings. The two disagreeing is itself the diagnosis. +static double autocorrPeriod(const std::vector& v, std::size_t from, std::size_t len, + std::int64_t minLag, std::int64_t maxLag) { + double e0 = 0.0; + for (std::size_t k = 0; k < len && from + k < v.size(); ++k) e0 += v[from + k] * v[from + k]; + if (e0 <= 0.0) return 0.0; + double best = -1e18; std::int64_t bestLag = 0; + std::vector score(static_cast(maxLag - minLag + 1), 0.0); + for (std::int64_t lag = minLag; lag <= maxLag; ++lag) { + double s = 0.0, e = 0.0; + for (std::size_t k = 0; k < len && from + k + static_cast(lag) < v.size(); + ++k) { + const double b = v[from + k + static_cast(lag)]; + s += v[from + k] * b; + e += b * b; + } + const double r = e > 0.0 ? s / std::sqrt(e0 * e) : 0.0; + score[static_cast(lag - minLag)] = r; + if (r > best) { best = r; bestLag = lag; } + } + // Parabolic refinement so the estimate isn't quantized to whole frames. + const std::size_t i = static_cast(bestLag - minLag); + double frac = 0.0; + if (i > 0 && i + 1 < score.size()) { + const double den = score[i - 1] - 2.0 * score[i] + score[i + 1]; + if (den < 0.0) frac = 0.5 * (score[i - 1] - score[i + 1]) / den; + } + return static_cast(bestLag) + frac; +} + +// The five strongest spectral peaks over a Hann-windowed segment, scanned on a fine period +// grid (Goertzel-style direct evaluation, no FFT-bin quantization). Prints period in frames +// and magnitude relative to the strongest — the decisive "is the rendered pitch wrong, or is +// there a second component fooling the zero-crossing count" measurement. +static void reportSpectrum(const char* label, const std::vector& v, std::size_t from, + std::size_t len, double wantPeriod) { + const int kGrid = 2000; + const double pLo = 30.0, pHi = 8000.0; + std::vector mag(static_cast(kGrid), 0.0); + std::vector per(static_cast(kGrid), 0.0); + for (int g = 0; g < kGrid; ++g) { + // Geometric grid: constant relative resolution across three octaves of period. + const double p = pLo * std::pow(pHi / pLo, static_cast(g) / (kGrid - 1)); + per[static_cast(g)] = p; + double re = 0.0, im = 0.0; + const double w = 2.0 * kPi / p; + for (std::size_t k = 0; k < len && from + k < v.size(); ++k) { + const double hann = 0.5 * (1.0 - std::cos(2.0 * kPi * static_cast(k) / + static_cast(len))); + const double x = v[from + k] * hann; + re += x * std::cos(w * static_cast(k)); + im += x * std::sin(w * static_cast(k)); + } + mag[static_cast(g)] = std::sqrt(re * re + im * im); + } + double top = 0.0; + for (double m : mag) top = std::max(top, m); + // Local maxima, ranked by MAGNITUDE (not by grid order) so the fundamental cannot be + // pushed off the list by low-level debris at a shorter period. + std::vector> peaks; // (magnitude, period) + for (int g = 1; g + 1 < kGrid; ++g) { + const std::size_t i = static_cast(g); + if (mag[i] <= mag[i - 1] || mag[i] < mag[i + 1]) continue; + if (mag[i] < 0.02 * top) continue; + peaks.emplace_back(mag[i], per[i]); + } + std::sort(peaks.begin(), peaks.end(), + [](const std::pair& a, const std::pair& b) { + return a.first > b.first; + }); + // Energy fraction OUTSIDE the fundamental's mainlobe — the honest "how much of this render + // is not the wanted tone" number, since a peak list alone can hide broadband debris. + double eTotal = 0.0, eFund = 0.0; + for (int g = 0; g < kGrid; ++g) { + const std::size_t i = static_cast(g); + const double e = mag[i] * mag[i]; + eTotal += e; + if (std::fabs(per[i] - wantPeriod) / wantPeriod < 0.06) eFund += e; + } + std::printf(" %s spectrum (want period %.1f fr); strongest peaks >2%% of max:\n", label, + wantPeriod); + for (std::size_t k = 0; k < peaks.size() && k < 8; ++k) { + std::printf(" period %8.1f fr rel %.4f%s\n", peaks[k].second, + peaks[k].first / top, + std::fabs(peaks[k].second - wantPeriod) / wantPeriod < 0.03 + ? " <-- the wanted tone" : ""); + } + std::printf(" energy outside the wanted tone's mainlobe: %.2f%%\n", + eTotal > 0.0 ? 100.0 * (1.0 - eFund / eTotal) : 0.0); +} + +// --------------------------------------------------------------------------------------- +// A. Splice geometry, observed off the shifter's own SpliceEvent stream +// --------------------------------------------------------------------------------------- + +struct SpliceStats { + long long count = 0; + double minReloc = 1e18, maxReloc = -1e18; + std::int64_t minLag = 1LL << 40, maxLag = -(1LL << 40); + double meanInterval = 0.0; + bool jumpAlwaysNominal = true; // |jump| == window on every splice (steady state) +}; + +// Drives a bare PitchShifter over the same feed schedule Voice uses, recording every splice. +// The audio is not kept — this measures the DECISIONS, not the sound. +static SpliceStats spliceGeometry(const std::vector& src, std::int64_t window, + double rate, double shift, std::size_t outFrames, + std::vector* audio = nullptr) { + PitchShifter ps; + ps.configure(window); + ps.prime(src.data(), window); + ps.setShiftRatio(shift); + ps.setFeedRate(rate); + StretchCursor cur; + cur.start(window); + loop::ResolvedLoop lp{}; // inactive: the source is long enough to run straight through + + SpliceStats st; + if (audio != nullptr) audio->assign(outFrames, 0.0); + std::size_t lastSpliceAt = 0; + double intervalSum = 0.0; + long long intervals = 0; + for (std::size_t i = 0; i < outFrames; ++i) { + const std::int64_t due = cur.due(rate); + AudioSample last = 0.0f; + bool fed = false; + for (std::int64_t k = 0; k < due; ++k) { + if (fed) ps.writeFrame(last); + const std::int64_t q = cur.next(lp); + last = (q >= 0 && static_cast(q) < src.size()) + ? src[static_cast(q)] : 0.0f; + fed = true; + } + const AudioSample o = fed ? ps.process(last) : ps.processNoInput(); + if (audio != nullptr) (*audio)[i] = static_cast(o); + const SpliceEvent& ev = ps.lastSplice(); + if (!ev.fired) continue; + ++st.count; + const double reloc = std::fabs(static_cast(ev.jump) - + static_cast(ev.lag) - ev.frac); + if (reloc < st.minReloc) st.minReloc = reloc; + if (reloc > st.maxReloc) st.maxReloc = reloc; + if (ev.lag < st.minLag) st.minLag = ev.lag; + if (ev.lag > st.maxLag) st.maxLag = ev.lag; + if (std::llabs(ev.jump) != window) st.jumpAlwaysNominal = false; + if (lastSpliceAt != 0) { intervalSum += static_cast(i - lastSpliceAt); ++intervals; } + lastSpliceAt = i; + } + st.meanInterval = intervals > 0 ? intervalSum / static_cast(intervals) : 0.0; + return st; +} + +// Is there a whole number of source periods inside the reachable relocation interval? +static bool alignmentReachable(double periodFrames, double lo, double hi, int* whichN) { + for (int n = 1; n <= 64; ++n) { + const double m = periodFrames * n; + if (m > hi) break; + if (m >= lo) { if (whichN != nullptr) *whichN = n; return true; } + } + if (whichN != nullptr) *whichN = 0; + return false; +} + +// --------------------------------------------------------------------------------------- +// 1. The reachable relocation interval, measured +// --------------------------------------------------------------------------------------- + +static void reportReachableInterval() { + std::printf("\n=== A. Reachable splice relocation interval (measured) ===\n"); + for (const auto& g : {std::pair{44100, 2205}, + std::pair{48000, 2400}}) { + const int sr = g.first; + const std::int64_t w = g.second; + // Broadband noise: every lag is a plausible candidate, so the search's own limits — + // not the source's periodicity — set the observed extremes. + std::vector noise(600000); + std::uint32_t rng = 12345u; + for (auto& x : noise) { + rng = rng * 1664525u + 1013904223u; + x = static_cast((static_cast(rng >> 8) / 8388608.0) - 1.0); + } + SpliceStats up = spliceGeometry(noise, w, 1.0, 1.5, 120000); // up-shift: jump +w + SpliceStats dn = spliceGeometry(noise, w, 1.0, 0.7, 120000); // down-shift: jump -w + const double lo = std::min(up.minReloc, dn.minReloc); + const double hi = std::max(up.maxReloc, dn.maxReloc); + std::printf(" %d Hz, window %lld frames (%.1f ms):\n", sr, static_cast(w), + 1000.0 * static_cast(w) / sr); + std::printf(" up-shift splices %lld, lag [%lld, %lld], reloc [%.2f, %.2f]\n", + up.count, static_cast(up.minLag), + static_cast(up.maxLag), up.minReloc, up.maxReloc); + std::printf(" down-shift splices %lld, lag [%lld, %lld], reloc [%.2f, %.2f]\n", + dn.count, static_cast(dn.minLag), + static_cast(dn.maxLag), dn.minReloc, dn.maxReloc); + std::printf(" observed reachable relocation interval: [%.2f, %.2f] frames " + "= [%.2f, %.2f] ms\n", lo, hi, 1000.0 * lo / sr, 1000.0 * hi / sr); + std::printf(" structural bound (window +/- window/4): [%lld, %lld]\n", + static_cast(w - w / 4), static_cast(w + w / 4)); + // The jump is nominal and the lag is inside +/- window/4 — the two facts the + // reachable interval is derived from. + CHECK(up.jumpAlwaysNominal && dn.jumpAlwaysNominal); + CHECK(up.minLag >= -(w / 4) && up.maxLag <= w / 4); + CHECK(dn.minLag >= -(w / 4) && dn.maxLag <= w / 4); + } +} + +// The reachability predicate over frequency, at both geometries. Pure arithmetic over the +// interval measured above — no render, stated as such. +static void reportReachabilityByFrequency() { + std::printf("\n=== A2. Alignment reachability by frequency (arithmetic, not rendered) ===\n"); + const double freqs[] = {12, 14, 16, 18, 20, 22, 24, 26, 26.6, 28, 30, 31, 31.9, 32, + 34, 36, 40, 50, 60, 80, 88.2, 100, 140, 200}; + for (const auto& g : {std::pair{44100, 2205}, + std::pair{48000, 2400}}) { + const int sr = g.first; + const std::int64_t w = g.second; + const double lo = static_cast(w - w / 4), hi = static_cast(w + w / 4); + std::printf(" %d Hz / window %lld, interval [%.0f, %.0f] frames:\n", sr, + static_cast(w), lo, hi); + for (double f : freqs) { + const double P = static_cast(sr) / f; + int n = 0; + const bool ok = alignmentReachable(P, lo, hi, &n); + if (ok) { + std::printf(" %6.1f Hz P=%8.1f ALIGNABLE (n=%d, n*P=%.1f)\n", f, P, n, + n * P); + } else { + // How far the nearest multiple sits outside the interval, and the phase error + // that residual forces at every splice. + double best = 1e18; double bestM = 0.0; + for (int k = 1; k <= 64; ++k) { + const double m = P * k; + const double d = m < lo ? lo - m : (m > hi ? m - hi : 0.0); + if (d < best) { best = d; bestM = m; } + } + std::printf(" %6.1f Hz P=%8.1f UNALIGNABLE (nearest n*P=%.1f, off by " + "%.1f frames = %.1f deg of phase)\n", + f, P, bestM, best, 360.0 * best / P); + } + } + } +} + +// --------------------------------------------------------------------------------------- +// 2. Voice-level renders at 30 Hz +// --------------------------------------------------------------------------------------- + +// The reassurance case: the SHIPPED default path. 30 Hz played at its root, rate 1.0, no +// transposition. The shift is exactly 1.0, so the tap's delay never drifts and no splice can +// fire; a primed shifter at unity is a bit-exact pass-through. Baseline is the SAME source +// through Varispeed at the root, which is a straight readPos_ += 1.0 read of the PCM — i.e. +// the unprocessed sample. This is NOT a comparison against a pre-change binary; it is the +// stronger claim that the path is transparent. +static void testRootRateUnityIsBitIdenticalToTheDirectRead() { + std::printf("\n=== B1. 30 Hz, root note, rate 1.0, no transposition ===\n"); + const int sr = 44100; + const std::int64_t w = 2205; + const std::size_t frames = 300000, outFrames = 250000; + const SampleData pres = sineSample(30.0, sr, frames, PitchEngine::Preserve); + const SampleData vari = sineSample(30.0, sr, frames, PitchEngine::Varispeed); + const std::vector p = renderVoice(pres, 60, 1.0, w, outFrames); + const std::vector v = renderVoice(vari, 60, 1.0, w, outFrames); + std::size_t firstDiff = outFrames; + for (std::size_t i = 0; i < outFrames; ++i) { + if (p[i] != v[i]) { firstDiff = i; break; } + } + std::printf(" Preserve vs Varispeed at root, %zu frames: %s\n", outFrames, + firstDiff == outFrames ? "BIT-IDENTICAL" + : "differ (first at frame ?)"); + if (firstDiff != outFrames) { + std::printf(" first difference at frame %zu (%.9f vs %.9f)\n", firstDiff, p[firstDiff], + v[firstDiff]); + } + CHECK(firstDiff == outFrames); + // And the same claim at the 48k geometry. + const SampleData pres48 = sineSample(30.0, 48000, frames, PitchEngine::Preserve); + const SampleData vari48 = sineSample(30.0, 48000, frames, PitchEngine::Varispeed); + const std::vector p48 = renderVoice(pres48, 60, 1.0, 2400, outFrames); + const std::vector v48 = renderVoice(vari48, 60, 1.0, 2400, outFrames); + bool same48 = true; + for (std::size_t i = 0; i < outFrames && same48; ++i) if (p48[i] != v48[i]) same48 = false; + std::printf(" same at 48k / window 2400: %s\n", same48 ? "BIT-IDENTICAL" : "DIFFER"); + CHECK(same48); + // Splice count on the same conditions, read off the shifter directly. + std::vector src(pres.frames.begin(), pres.frames.end()); + const SpliceStats st = spliceGeometry(src, w, 1.0, 1.0, outFrames); + std::printf(" splices fired over %zu frames at shift 1.0, rate 1.0: %lld\n", outFrames, + st.count); + CHECK(st.count == 0); + + // Onset at a MUCH larger window — the cost side of any window-resize option. prime() + // parks the tap on src[0] whatever the window, so frame 0 must still be source frame 0. + // Started at quarter-phase so src[0] is FULL SCALE, not the zero a sine would give: a + // frame-0 match against 0.0 would also pass on a voice that produced silence. + const SampleData cosPhase = + sineSample(30.0, sr, 300000, PitchEngine::Preserve, kPi / 2.0); + CHECK(cosPhase.frames[0] == 1.0f); + for (std::int64_t big : {std::int64_t{2205}, std::int64_t{8820}}) { + const std::vector up = renderVoice(cosPhase, 67, 1.0, big, 64); // +7 st + std::printf(" window %5lld, +7 st: out[0]=%.9f (src[0]=%.9f), |out| over frames 1..63 " + "min %.6f\n", static_cast(big), up[0], + static_cast(cosPhase.frames[0]), + *std::min_element(up.begin() + 1, up.end(), + [](double a, double b) { return std::fabs(a) < std::fabs(b); })); + CHECK(up[0] == static_cast(cosPhase.frames[0])); // zero added latency + } + // A sample SHORTER than the window: start() primes the whole playable span and freezes + // the writer immediately. A larger window moves that threshold, so check it still speaks + // on frame 0 at the largest window swept below. + const SampleData shortSample = + sineSample(30.0, sr, 3000, PitchEngine::Preserve, kPi / 2.0); + const std::vector shortOut = renderVoice(shortSample, 67, 1.0, 8820, 64); + std::printf(" 3000-frame sample under a 8820-frame window, +7 st: out[0]=%.9f src[0]=%.9f\n", + shortOut[0], static_cast(shortSample.frames[0])); + CHECK(shortOut[0] == static_cast(shortSample.frames[0])); +} + +// One measured row: render through the Voice and report every metric for that condition. +static void measureRow(const char* label, double freqHz, int sr, std::int64_t window, + int note, double rate) { + const std::size_t frames = 900000; + const std::size_t outFrames = 300000; + const SampleData s = sineSample(freqHz, sr, frames, PitchEngine::Preserve); + const double shift = std::pow(2.0, (note - 60) / 12.0); + const std::vector out = renderVoice(s, note, rate, window, outFrames); + + bool finite = true; + double peak = 0.0; + for (double x : out) { if (!std::isfinite(x)) finite = false; peak = std::max(peak, std::fabs(x)); } + + const double srcPeriod = static_cast(sr) / freqHz; + const double wantPeriod = srcPeriod / shift; // pitch is the TAP's, not the feed's + const double wantCpf = 1.0 / wantPeriod; + const std::size_t from = 40000, to = 280000; + const double gotPeriod = periodIn(out, from, to); + double worstStep = 0.0; + const double drift = phaseDriftCycles(out, from, to, wantCpf, &worstStep); + const double resid = medianResidual(out, from, to, wantCpf); + + std::vector src(s.frames.begin(), s.frames.end()); + const SpliceStats st = spliceGeometry(src, window, rate, shift, outFrames); + const double lo = static_cast(window - window / 4); + const double hi = static_cast(window + window / 4); + int n = 0; + const bool reach = alignmentReachable(srcPeriod, lo, hi, &n); + + // Effective frequency error implied by the drift, and the phase step it works out to per + // splice — the number that says whether a splice is stepping the phase or not. + const double driftPerSplice = st.count > 0 ? drift / static_cast(st.count) : 0.0; + std::printf(" %-26s f=%6.1f Hz shift=%.4f rate=%.2f | period got %8.2f want %8.2f " + "(%+.2f%%) | splices %4lld every %7.0f fr | phase drift %+8.3f cyc " + "(%+7.1f deg/splice, worst step %.1f deg) | resid %.4f | peak %.3f | " + "align %s%s\n", + label, freqHz, shift, rate, gotPeriod, wantPeriod, + wantPeriod > 0.0 ? 100.0 * (gotPeriod - wantPeriod) / wantPeriod : 0.0, + st.count, st.meanInterval, drift, 360.0 * driftPerSplice, 360.0 * worstStep, + resid, peak, reach ? "YES" : "NO", + reach ? "" : " <-- no whole period in the reachable interval"); + CHECK(finite); +} + +// Three independent pitch estimators plus the spectrum, on one condition. Where the +// zero-crossing count and the autocorrelation disagree, the render is not simply detuned — +// something else is crossing zero. +static void deepDive(const char* label, double freqHz, int sr, std::int64_t window, int note, + double rate) { + const SampleData s = sineSample(freqHz, sr, 900000, PitchEngine::Preserve); + const double shift = std::pow(2.0, (note - 60) / 12.0); + const std::vector out = renderVoice(s, note, rate, window, 300000); + const double wantPeriod = (static_cast(sr) / freqHz) / shift; + const double zc = periodIn(out, 40000, 280000); + // Search bounded to [0.5, 1.7] x the wanted period: a pure sine autocorrelates equally at + // EVERY multiple of its period, so an unbounded search reports 2P about half the time. + const double ac = autocorrPeriod(out, 60000, 60000, + std::max(40, + static_cast(wantPeriod * 0.5)), + static_cast(wantPeriod * 1.7)); + std::printf(" %s (f=%.1f Hz, shift %.4f, rate %.2f, want period %.1f fr):\n", label, freqHz, + shift, rate, wantPeriod); + std::printf(" zero-crossing period %.2f (%+.2f%%) | autocorrelation period %.2f " + "(%+.2f%%)\n", zc, 100.0 * (zc - wantPeriod) / wantPeriod, ac, + 100.0 * (ac - wantPeriod) / wantPeriod); + reportSpectrum(label, out, 60000, 131072, wantPeriod); +} + +static void reportTransposedAt30Hz() { + std::printf("\n=== B2. 30 Hz transposed, rate 1.0 (44.1k / window 2205) ===\n"); + measureRow("30 Hz +2 st", 30.0, 44100, 2205, 62, 1.0); + measureRow("30 Hz +7 st", 30.0, 44100, 2205, 67, 1.0); + measureRow("30 Hz -7 st", 30.0, 44100, 2205, 53, 1.0); + std::printf("\n=== B3. 30 Hz stretched, no transposition (44.1k / window 2205) ===\n"); + measureRow("30 Hz rate 0.5", 30.0, 44100, 2205, 60, 0.5); + measureRow("30 Hz rate 2.0", 30.0, 44100, 2205, 60, 2.0); + std::printf("\n=== B4. the same six at 48k / window 2400 ===\n"); + measureRow("30 Hz +2 st @48k", 30.0, 48000, 2400, 62, 1.0); + measureRow("30 Hz +7 st @48k", 30.0, 48000, 2400, 67, 1.0); + measureRow("30 Hz rate 2.0 @48k", 30.0, 48000, 2400, 60, 2.0); +} + +// Where does the behaviour actually turn over? Swept at a fixed, modest transposition so the +// only thing changing is the source period against the reachable interval. +static void reportFrequencySweep() { + std::printf("\n=== B5. Frequency sweep, +2 st, rate 1.0 (44.1k / window 2205) ===\n"); + const double freqs[] = {20, 22, 24, 25, 26, 26.5, 27, 28, 29, 30, 31, 31.5, 32, 33, + 34, 36, 40, 45, 50, 60, 70, 80, 88.2, 100, 120, 140, 170, 200}; + for (double f : freqs) measureRow("sweep +2 st", f, 44100, 2205, 62, 1.0); + std::printf("\n=== B6. Same sweep at rate 2.0, NO transposition ===\n"); + for (double f : freqs) measureRow("sweep rate 2.0", f, 44100, 2205, 60, 2.0); +} + +// --------------------------------------------------------------------------------------- +// 3. The P=500 case: geometric, or cadence? +// --------------------------------------------------------------------------------------- + +// pitch_shift_tests' floor probe fails at source period 500 frames, rate 2.0, shift 0.25 and +// holds at 600/700. Under the geometric claim, alignment needs a whole number of source +// periods in [0.75w, 1.25w] = [1654, 2756]. This reports whether that is satisfied for each of +// the three periods — separating "no aligned landing point exists" (geometric) from "an +// aligned landing point exists but the cadence is too fast to use it" (the inequality already +// in time_stretch.h). +static void reportFloorProbeMechanism() { + std::printf("\n=== C. The P=500/600/700 floor probe: which mechanism? ===\n"); + const std::int64_t w = 2205; + const int sr = 44100; + const double rate = 2.0; + const double shift = std::pow(2.0, -24.0 / 12.0); // 0.25 + const double lo = static_cast(w - w / 4), hi = static_cast(w + w / 4); + for (double period : {500.0, 600.0, 700.0}) { + int n = 0; + const bool reach = alignmentReachable(period, lo, hi, &n); + const double freq = static_cast(sr) / period; + // The cadence inequality from time_stretch.h, evaluated for this row. + const double cadence = static_cast(w) / std::fabs(rate - shift); + const double outPeriod = period / shift; + std::printf(" P=%5.0f (%.1f Hz): alignable in [%.0f,%.0f]? %s%s | cadence %.0f fr vs " + "output period %.0f fr -> %s\n", + period, freq, lo, hi, reach ? "YES" : "NO", + reach ? "" : " (geometric failure)", + cadence, outPeriod, + cadence < outPeriod ? "SPLICE INSIDE A CYCLE (cadence failure)" : "ok"); + measureRow("floor probe", freq, sr, w, 60 - 24, rate); + } + // The probe's OWN signal, reproduced exactly: a bare PitchShifter fed by the same + // schedule test_pitch_shift.cpp's runStretch uses, not the Voice. Its zero-crossing + // number is the one that is currently RED, so it is the one that has to be explained. + std::printf("\n --- the probe's exact signal (bare PitchShifter, runStretch schedule) ---\n"); + for (double period : {500.0, 600.0, 700.0}) { + const std::size_t srcLen = 400000; + std::vector src(srcLen); + for (std::size_t i = 0; i < srcLen; ++i) { + src[i] = static_cast( + std::sin(2.0 * kPi * static_cast(i) / period)); + } + std::vector out; + const SpliceStats st = spliceGeometry(src, w, rate, shift, 60000, &out); + const double want = period / shift; + const double zc = periodIn(out, 20000, 50000); + const double ac = autocorrPeriod(out, 20000, 20000, + static_cast(want * 0.5), + static_cast(want * 1.7)); + std::printf(" P=%.0f: zero-crossing %.2f (%+.2f%%) | autocorrelation %.2f (%+.2f%%) " + "| splices %lld every %.0f fr\n", period, zc, 100.0 * (zc - want) / want, + ac, 100.0 * (ac - want) / want, st.count, st.meanInterval); + reportSpectrum("probe", out, 20000, 32768, want); + } + + std::printf("\n --- independent pitch estimators on the same three (through the Voice) ---\n"); + deepDive("P=500", 44100.0 / 500.0, sr, w, 36, rate); + deepDive("P=600", 44100.0 / 600.0, sr, w, 36, rate); + deepDive("P=700", 44100.0 / 700.0, sr, w, 36, rate); + std::printf("\n --- and on the geometric cases, for contrast ---\n"); + deepDive("30 Hz +2 st rate 1.0", 30.0, sr, w, 62, 1.0); + deepDive("30 Hz rate 2.0", 30.0, sr, w, 60, 2.0); + deepDive("29 Hz rate 2.0", 29.0, sr, w, 60, 2.0); +} + +// What would a bigger window buy? The reachable interval is [0.75w, 1.25w], so it contains a +// whole number of source periods for EVERY period P <= 0.5w — i.e. a window of at least TWO +// source periods makes alignment reachable unconditionally. This sweeps 30 Hz across windows +// spanning that threshold (1470 * 2 = 2940 frames = 66.7 ms at 44.1k) and reports what +// actually changes. The window is an ARGUMENT to configure(); nothing shipped is altered. +static void reportWindowSweep() { + std::printf("\n=== D. Window sweep at 30 Hz — what a larger window would buy ===\n"); + const int sr = 44100; + const double P = static_cast(sr) / 30.0; + std::printf(" source period %.1f frames; alignment is unconditional once window >= 2P = " + "%.0f frames (%.1f ms)\n", P, 2.0 * P, 2000.0 * P / sr); + for (std::int64_t w : {std::int64_t{2205}, std::int64_t{2646}, std::int64_t{2940}, + std::int64_t{3528}, std::int64_t{4410}, std::int64_t{8820}}) { + const double lo = static_cast(w - w / 4), hi = static_cast(w + w / 4); + int n = 0; + const bool reach = alignmentReachable(P, lo, hi, &n); + std::printf("\n window %lld fr (%.1f ms), interval [%.0f, %.0f]: %s\n", + static_cast(w), 1000.0 * static_cast(w) / sr, lo, hi, + reach ? "ALIGNABLE" : "unalignable"); + // Per-voice Preserve state: two shifter rings of 2*window floats (L/R) plus the + // window-sized prime scratch = 5*window floats (voice.cpp presizePreserveShifters). + const double bytes = 5.0 * static_cast(w) * 4.0; + std::printf(" per-voice Preserve state %.1f KB; at the 32-voice ceiling %.2f MB\n", + bytes / 1024.0, 32.0 * bytes / (1024.0 * 1024.0)); + measureRow(" 30 Hz +2 st", 30.0, sr, w, 62, 1.0); + measureRow(" 30 Hz rate 2.0", 30.0, sr, w, 60, 2.0); + deepDive(" +2 st", 30.0, sr, w, 62, 1.0); + deepDive(" rate 2.0", 30.0, sr, w, 60, 2.0); + } +} + +// Alignable neighbours under identical conditions — without these the out-of-band-energy +// numbers above have no scale. +static void reportAlignableControls() { + std::printf("\n=== E. Alignable controls (same conditions, a frequency that CAN align) ===\n"); + deepDive("34 Hz +2 st rate 1.0", 34.0, 44100, 2205, 62, 1.0); + deepDive("34 Hz rate 2.0", 34.0, 44100, 2205, 60, 2.0); + deepDive("20 Hz +2 st rate 1.0", 20.0, 44100, 2205, 62, 1.0); + deepDive("220 Hz +2 st rate 1.0", 220.0, 44100, 2205, 62, 1.0); + deepDive("220 Hz rate 2.0", 220.0, 44100, 2205, 60, 2.0); +} + +int main() { + reportReachableInterval(); + reportReachabilityByFrequency(); + testRootRateUnityIsBitIdenticalToTheDirectRead(); + reportTransposedAt30Hz(); + reportFrequencySweep(); + reportFloorProbeMechanism(); + reportAlignableControls(); + reportWindowSweep(); + + if (g_fail == 0) { + std::printf("\nall preserve_low_frequency measurements completed\n"); + return 0; + } + std::printf("\n%d preserve_low_frequency check(s) failed\n", g_fail); + return 1; +} diff --git a/tests/test_sampler_core.cpp b/tests/test_sampler_core.cpp index 16cc49c..d593670 100644 --- a/tests/test_sampler_core.cpp +++ b/tests/test_sampler_core.cpp @@ -20,8 +20,11 @@ #include "../src/core/instrument/engine/voice_engine.h" #include +#include #include +#include #include +#include #include using namespace reasampler; @@ -2880,6 +2883,393 @@ static void testPreserveSubWindowSampleNoZeroPadInRing() { CHECK(blockPeak(out, 0, frames) > 0.5); // and it genuinely played at full level } +// --------------------------------------------------------------------------- +// The Preserve read path's stretch generalization: the source is consumed at the playback +// rate while the shifter's read tap runs at the transposition, over ONE delay ring. Only +// their DIFFERENCE reaches the splice machinery. +// --------------------------------------------------------------------------- + +// FNV-1a over the raw float bits — an exact-stream witness, not a tolerance. +static std::uint64_t hashStream(const std::vector& v) { + std::uint64_t h = 1469598103934665603ull; + for (const AudioSample s : v) { + std::uint32_t bits = 0; + std::memcpy(&bits, &s, sizeof(bits)); + for (int b = 0; b < 4; ++b) { + h ^= static_cast((bits >> (8 * b)) & 0xffu); + h *= 1099511628211ull; + } + } + return h; +} + +// A source with no symmetry a shifter could accidentally satisfy: a sine at a non-integer +// period plus a deterministic pseudo-random dither, so any change in the splice schedule, +// the fed frame sequence or the tap position moves the hash. +static SampleData stretchProbeSample(std::size_t frames, bool stereo) { + SampleData s; + s.frames.resize(frames); + if (stereo) s.framesR.resize(frames); + std::uint32_t lcg = 12345u; + for (std::size_t i = 0; i < frames; ++i) { + lcg = lcg * 1664525u + 1013904223u; + const double n = static_cast(lcg >> 8) / 8388608.0 - 1.0; // [-1,1) + const double t = static_cast(i); + s.frames[i] = static_cast(0.8 * std::sin(2.0 * kPi * t / 196.37) + 0.1 * n); + if (stereo) { + s.framesR[i] = + static_cast(0.8 * std::sin(2.0 * kPi * t / 123.13) - 0.1 * n); + } + } + s.rootNote = 60; + s.sampleRate = 44100; + s.play.adsr = flatAdsr(); + s.play.pitchEngine = PitchEngine::Preserve; + return s; +} + +// Renders one raw Voice (not through VoiceEngine, which publishes no rate) for `outFrames`. +static void renderVoice(const SampleData& s, int note, double rate, std::int64_t window, + bool stereo, std::vector& l, std::vector& r) { + Voice v; + v.presizePreserveShifters(window); + v.start(note, 127, s, /*declickTakeover=*/false, rate); + for (std::size_t i = 0; i < l.size(); ++i) { + if (stereo) { + AudioSample a = 0.0f, b = 0.0f; + v.renderFrameStereo(a, b); + l[i] = a; + r[i] = b; + } else { + l[i] = v.renderFrame(); + } + } +} + +// --- The null case, asserted against a baseline the SHIPPED engine produced. --- +// The four constants below are a witness against `phase-g`'s tip, commit 0a7778b — the last +// commit before this track's rate seam — not a self-consistency check. To re-derive: check +// out 0a7778b, add this file's stretchProbeSample/hashStream/renderVoice/test body to it, and +// drop the trailing `, rate` argument from renderVoice's `v.start(...)` call (0a7778b's +// Voice::start has no 5th parameter) — then build, run, and print the hashes. A change here is +// a change to what every already-saved project sounds like — re-derive the cause before +// re-baselining. +static void testPreserveUnityRateIsBitIdenticalToTheShippedRead() { + const std::int64_t w = 2205; // the product window at 44.1k + const std::size_t n = 6000; + struct Case { + int note; + bool stereo; + bool loop; + std::uint64_t hashL; + std::uint64_t hashR; + }; + const Case cases[] = { + {60, false, false, 16118581538698271917ull, 0ull}, // on root: unity shift + {67, false, false, 17268489061432447375ull, 0ull}, // +7 st: real splices + {55, false, false, 17626155132441637249ull, 0ull}, // -5 st: down-shift + {67, true, true, 116487689553455907ull, 9528575457480122654ull}, // stereo linked + loop + }; + for (const Case& c : cases) { + SampleData s = stretchProbeSample(4000, c.stereo); + if (c.loop) { + s.loop.hasLoop = true; + s.loop.start = 1200; + s.loop.end = 3600; + s.loopCrossfadeFrames = 256; + } + std::vector l(n), r(c.stereo ? n : 0); + renderVoice(s, c.note, /*rate=*/1.0, w, c.stereo, l, r); + const std::uint64_t hl = hashStream(l); + CHECK(hl == c.hashL); + if (hl != c.hashL) std::printf(" note %d L hash %lluull\n", c.note, hl); + if (c.stereo) { + const std::uint64_t hr = hashStream(r); + CHECK(hr == c.hashR); + if (hr != c.hashR) std::printf(" note %d R hash %lluull\n", c.note, hr); + } + } +} + +// --- Rate changes DURATION only; the transposition alone sets pitch. --- +static void testPreserveStretchChangesDurationNotPitch() { + // Gate, no loop: the voice's life is exactly how long the source lasts, so the frame at + // which it goes idle IS the note's duration. + const std::int64_t w = 1024; + const std::size_t frames = 24000; + const double srcPeriod = 160.0; + SampleData s; + s.frames.resize(frames); + for (std::size_t i = 0; i < frames; ++i) { + s.frames[i] = static_cast(std::sin(2.0 * kPi * static_cast(i) / srcPeriod)); + } + s.rootNote = 60; + s.play.adsr = flatAdsr(); + s.play.pitchEngine = PitchEngine::Preserve; + + auto run = [&](double rate, PitchEngine engine, std::size_t& lifeFrames) { + SampleData local = s; + local.play.pitchEngine = engine; + Voice v; + v.presizePreserveShifters(w); + v.start(60, 127, local, /*declickTakeover=*/false, rate); + std::vector out; + out.reserve(frames * 3); + lifeFrames = 0; + for (std::size_t i = 0; i < frames * 3 && v.active(); ++i) { + out.push_back(v.renderFrame()); + ++lifeFrames; + } + return out; + }; + + std::size_t lifeUnity = 0, lifeSlow = 0, lifeFast = 0; + const std::vector unity = run(1.0, PitchEngine::Preserve, lifeUnity); + const std::vector slow = run(0.5, PitchEngine::Preserve, lifeSlow); + const std::vector fast = run(2.0, PitchEngine::Preserve, lifeFast); + + // Duration scales by 1/rate (the small excess over the source length is the terminal + // declick ring-out Preserve ends on). + CHECK(approx(static_cast(lifeUnity), 24000.0, 200.0)); + CHECK(approx(static_cast(lifeSlow), 48000.0, 400.0)); + CHECK(approx(static_cast(lifeFast), 12000.0, 200.0)); + + // ...and the pitch does not move with it. Measured away from the onset and the tail. + auto period = [](const std::vector& v, std::size_t from, std::size_t to) { + double sum = 0.0; + std::size_t prev = 0, count = 0; + for (std::size_t i = from + 1; i < to && i < v.size(); ++i) { + if (v[i - 1] <= 0.0f && v[i] > 0.0f) { + if (count > 0) sum += static_cast(i - prev); + prev = i; + ++count; + } + } + return count > 1 ? sum / static_cast(count - 1) : 0.0; + }; + CHECK(approx(period(unity, 2000, 9000), srcPeriod, 8.0)); + CHECK(approx(period(slow, 2000, 9000), srcPeriod, 8.0)); + CHECK(approx(period(fast, 2000, 9000), srcPeriod, 8.0)); + + // The non-tautology witness: VARISPEED is the engine that couples them. Reaching the same + // durations there costs exactly the pitch change Preserve refuses to make — so the three + // equal periods above are a property of the stretcher, not of the measurement. + std::size_t lifeVari = 0; + const std::vector vari = run(0.5, PitchEngine::Varispeed, lifeVari); + CHECK(approx(static_cast(lifeVari), 24000.0, 200.0)); // rate ignored under Varispeed + SampleData down = s; + down.play.pitchEngine = PitchEngine::Varispeed; + Voice vv; + vv.presizePreserveShifters(w); + vv.start(48, 127, down); // -12 st under Varispeed: duration doubles AND pitch halves + std::vector variDown; + std::size_t variLife = 0; + for (std::size_t i = 0; i < frames * 3 && vv.active(); ++i) { + variDown.push_back(vv.renderFrame()); + ++variLife; + } + CHECK(approx(static_cast(variLife), 48000.0, 200.0)); // same duration... + CHECK(approx(period(variDown, 2000, 9000), srcPeriod * 2.0, 16.0)); // ...at half pitch +} + +// --- The onset is a regression surface: no added latency at ANY rate. --- +static void testPreserveStretchSpeaksOnFrameZeroAtEveryRate() { + const std::int64_t w = 2048; + SampleData s = stretchProbeSample(12000, false); + s.startFrame = 500; // and the first output frame is the START frame, not frame 0 + for (double rate : {0.5, 1.0, 2.0}) { + for (int note : {48, 60, 67}) { + Voice v; + v.presizePreserveShifters(w); + v.start(note, 127, s, /*declickTakeover=*/false, rate); + const AudioSample first = v.renderFrame(); + // The primed ring parks the tap ON the start frame, so output frame 0 is source + // frame `startFrame` exactly — at every rate and every transposition. A stretcher + // that buffered a window before speaking would fail here, which is the whole point. + // This bit-exact check is what actually carries "no first-frame smear"; the loop + // below is a coarser, complementary DROPOUT detector (see its own comment). + CHECK(first == s.frames[500]); + // ...and it keeps speaking: no first-window DROPOUT while the schedule settles. + // `lo > 0.5` over twenty 256-frame peak windows catches a gap of roughly a window, + // but a smeared or phase-scrambled first window can still peak above 0.5 and pass + // here — it cannot see that; the CHECK above is what does. The 256-frame measuring + // window spans most of a period even at the lowest note tested (-12 st stretches + // the probe's 196-frame period to 393), so a continuous tone peaks well above the + // floor in every one of them and only a real gap can sink it. + double lo = 1e9; + for (int i = 0; i < 20; ++i) { + double peak = 0.0; + for (int k = 0; k < 256; ++k) { + peak = (std::max)(peak, std::fabs(static_cast(v.renderFrame()))); + } + lo = (std::min)(lo, peak); + } + if (!(lo > 0.5)) std::printf(" rate %.2f note %d: lo %.3f\n", rate, note, lo); + CHECK(lo > 0.5); + } + } +} + +// --- "Loop the source, shift the output" is unweakened by a stretch. --- +static void testPreserveStretchLoopsTheSourceSpan() { + for (double rate : {0.5, 1.0, 2.0}) { + for (int note : {48, 60, 72}) { + SampleData s; + s.frames.resize(200, 0.0f); + for (int i = 60; i < 120; ++i) s.frames[i] = 0.5f; + s.rootNote = 60; + s.sampleRate = 48000; + s.loop.hasLoop = true; + s.loop.start = 80; + s.loop.end = 120; + s.play.adsr = flatAdsr(); + s.play.pitchEngine = PitchEngine::Preserve; + Voice v; + v.presizePreserveShifters(64); + v.start(note, 127, s, /*declickTakeover=*/false, rate); + std::vector out(4000); + for (std::size_t i = 0; i < out.size(); ++i) out[i] = v.renderFrame(); + // The loop is a SOURCE-frame fact, so it keeps the voice alive and at level for as + // long as it is held, whatever the rate consumes it at. + CHECK(v.active()); + double sum = 0.0; + for (std::size_t i = out.size() - 200; i < out.size(); ++i) sum += out[i]; + CHECK(approx(sum / 200.0, 0.5, 0.05)); + } + } + + // The two assertions above hold even if stretchRate_ were ignored outright — the loop's + // constant content proves nothing about cadence. A one-time marker AFTER the primed window + // but BEFORE the loop start is the source-frame witness that the feed genuinely consumes + // source AT THE RATE: note-on primes the ring with the first `window` source frames up + // front (played back at 1 frame/output-frame, independent of rate — a marker inside that + // span was measured landing at a FIXED output frame at every rate, confirming it is not a + // rate witness). Past it, new content only enters the ring via the ongoing due()-scheduled + // feed, at `rate` source frames per output frame on average: the marker's single output + // appearance lands at `window + (markerFrame - window) / rate` output frames. Note == root + // (shift == 1.0), isolating the rate's effect from the pitch engine's own transposition. + // + // Excludes rate 2.0: at shift 1.0 that is drift = |rate-shift| = 1.0 exactly, and this + // geometry's own splice trigger (0.75x window output frames from note-on, measured) fires + // BEFORE the primed span even finishes playing back (< window frames) whenever drift >= + // ~0.75 — so no marker placed "past the prime" can be reached before a splice relocates + // the tap first. Confirmed by measurement, not assumed: a rate-2.0 attempt at this marker + // came back with the tap having moved on (no witness value in the output at all). The + // write-side consumption-at-the-rate claim at every rate, splice-immune because it never + // goes through the shifter, is what test_time_stretch.cpp's StretchCursor tests assert. + // + // A marker placed INSIDE the steady-state loop instead would NOT show rate-dependence + // either: once ring-resident, the read tap's own pace is governed by SHIFT alone ("shift + // the output" — ratio_ advances posA_ every output frame unconditionally), so it revisits + // every loopLength ring slots at 1 slot/output-frame regardless of how fast the writer + // filled them — confirmed by measurement (median recurrence gap 200 frames at rate 0.5, + // 1.0 AND 2.0 alike, for a 200-frame loop). Rate governs the feed/splice cadence, not the + // loop's own output period, once its content is already in the ring. + for (double rate : {0.5, 1.0}) { + SampleData s; + s.frames.assign(1000, 0.0f); + for (int i = 300; i < 900; ++i) s.frames[i] = 0.5f; + s.frames[650] = 1.0f; // past the 600-frame primed span, before the loop at 700 + s.rootNote = 60; + s.sampleRate = 48000; + s.loop.hasLoop = true; + s.loop.start = 700; + s.loop.end = 900; + s.play.adsr = flatAdsr(); + s.play.pitchEngine = PitchEngine::Preserve; + Voice v; + v.presizePreserveShifters(600); + v.start(60, 127, s, /*declickTakeover=*/false, rate); // root note: shift == 1.0 + const std::size_t total = 3000; + std::vector out(total); + for (std::size_t i = 0; i < total; ++i) out[i] = v.renderFrame(); + std::size_t hitAt = 0; + for (std::size_t i = 0; i < total; ++i) { + if (out[i] > 0.7f) { hitAt = i; break; } + } + CHECK(hitAt > 0); + const double want = 600.0 + (650.0 - 600.0) / rate; + if (!approx(static_cast(hitAt), want, want * 0.15 + 5.0)) { + std::printf(" rate %.2f: marker at frame %zu want %.2f\n", rate, hitAt, want); + } + CHECK(approx(static_cast(hitAt), want, want * 0.15 + 5.0)); + } +} + +// --- The 32-voice measurement gate. Asserts correctness; PRINTS the cost, which is the +// number reported for the algorithm decision (meaningful only in a Release build). +// +// Methodology: std::chrono::steady_clock (not std::clock() — a single wall-clock diff has no +// warm-up and no spread), kWarmupReps discarded, kTimedReps repetitions per rate, median + +// [min, max] reported. secs is wall-clock for 1.0 s of audio on ONE thread with no other work +// scheduled onto it, so 100*secs is % of REALTIME consumed — not "% of one core" (that would +// additionally claim core-pinned exclusivity this benchmark never establishes). +// +// A separate, one-off Release A/B (unity-now vs the pre-stretch build at commit 0a7778b, same +// methodology, standalone harness outside this tree) found the two statistically +// indistinguishable at ~79-82 ns/voice/frame; that is a point-in-time finding to re-derive if +// this path changes materially, not a hardcoded regression bound here. --- +static void testPreserveStretchThirtyTwoVoicesHoldUp() { + const std::int64_t w = 2205; // the product window at 44.1k + const std::size_t blockFrames = 44100; // one second of audio + const std::size_t voiceCount = 32; + const int kWarmupReps = 2; + const int kTimedReps = 7; + SampleData s = stretchProbeSample(200000, true); + s.loop.hasLoop = true; // held notes: all 32 sound for the whole run + s.loop.start = 40000; + s.loop.end = 160000; + s.loopCrossfadeFrames = 1024; + + // 1.0 is the reference: it is the cost the shipped Preserve read already carries, so the + // two stretched rows are read as a delta against it rather than in isolation. + for (double rate : {1.0, 0.5, 2.0}) { + std::vector nsPerVoiceFrame; + nsPerVoiceFrame.reserve(kTimedReps); + for (int rep = 0; rep < kWarmupReps + kTimedReps; ++rep) { + std::vector voices(voiceCount); + for (std::size_t i = 0; i < voiceCount; ++i) { + voices[i].presizePreserveShifters(w); + voices[i].start(48 + static_cast(i), 100, s, /*declickTakeover=*/false, + rate); + } + const auto t0 = std::chrono::steady_clock::now(); + double guard = 0.0; + std::size_t sounding = 0; + for (std::size_t f = 0; f < blockFrames; ++f) { + AudioSample l = 0.0f, r = 0.0f; + for (std::size_t i = 0; i < voiceCount; ++i) { + AudioSample a = 0.0f, b = 0.0f; + voices[i].renderFrameStereo(a, b); + l += a; + r += b; + } + guard += static_cast(l) + static_cast(r); + CHECK(std::isfinite(l) && std::isfinite(r)); + } + const double secs = + std::chrono::duration(std::chrono::steady_clock::now() - t0).count(); + for (std::size_t i = 0; i < voiceCount; ++i) { + if (voices[i].active()) ++sounding; + } + CHECK(sounding == voiceCount); // all 32 held the whole second (the loop kept them up) + CHECK(std::fabs(guard) > 0.0); // ...and genuinely produced audio + if (rep >= kWarmupReps) { + nsPerVoiceFrame.push_back(secs * 1e9 / (static_cast(blockFrames) * + static_cast(voiceCount))); + } + } + std::sort(nsPerVoiceFrame.begin(), nsPerVoiceFrame.end()); + const double medianNs = nsPerVoiceFrame[nsPerVoiceFrame.size() / 2]; + const double secsAtMedian = + medianNs * static_cast(blockFrames) * static_cast(voiceCount) / 1e9; + std::printf(" [measure] 32 stereo Preserve voices @ rate %.2f: median %.1f ns/voice/" + "frame [%.1f .. %.1f] over %d reps (%.1f%% of realtime at the median)\n", + rate, medianNs, nsPerVoiceFrame.front(), nsPerVoiceFrame.back(), kTimedReps, + 100.0 * secsAtMedian); + } +} + int main() { testEveryKeyPlaysTheLoadedCapture(); testUnplayableCaptureRefusesEveryNote(); @@ -3006,6 +3396,13 @@ int main() { testPreservePrimeStopsAtTriggerPlayEnd(); testPreserveSubWindowSampleNoZeroPadInRing(); + // The Preserve read path's stretch generalization. + testPreserveUnityRateIsBitIdenticalToTheShippedRead(); + testPreserveStretchChangesDurationNotPitch(); + testPreserveStretchSpeaksOnFrameZeroAtEveryRate(); + testPreserveStretchLoopsTheSourceSpan(); + testPreserveStretchThirtyTwoVoicesHoldUp(); + if (g_fail == 0) { std::printf("all sampler_core tests passed\n"); return 0; diff --git a/tests/test_time_stretch.cpp b/tests/test_time_stretch.cpp new file mode 100644 index 0000000..b01b831 --- /dev/null +++ b/tests/test_time_stretch.cpp @@ -0,0 +1,155 @@ +// Standalone tests for reasampler::instrument::engine::StretchCursor — the Preserve read's +// source-feed schedule. No VST3, no REAPER, no vendor, no test framework. +// +// Covers: +// 1. rate 1.0 is EXACTLY one source frame per output frame, forever and with no residue — +// the mechanism behind the "unity is bit-identical to the shipped Preserve read" gate. +// 2. the schedule tracks the rate: over N output frames the cursor consumes N*rate source +// frames to within one, at rates either side of unity and at irrational ones. +// 3. the per-output-frame feed count never exceeds kMaxFeedPerFrame — the bound that makes +// a variable-length feed loop RT-safe. +// 4. the clamp: out-of-range folds to the bounds, unusable input folds to unity (never to a +// silent stall or a quarter-speed surprise). +// 5. the sustain loop wraps the cursor and never lets it leave [start, end) — "loop the +// source" holds at every rate, including one that steps over the loop end. + +#include "../src/core/instrument/engine/time_stretch.h" + +#include +#include +#include + +using namespace reasampler::instrument::engine; +using reasampler::instrument::engine::loop::ResolvedLoop; + +static int g_fail = 0; +#define CHECK(cond) do { if(!(cond)) { \ + std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) + +static ResolvedLoop noLoop() { return ResolvedLoop{}; } + +static ResolvedLoop loopSpan(std::int64_t start, std::int64_t end) { + ResolvedLoop lp; + lp.active = true; + lp.start = start; + lp.end = end; + lp.length = end - start; + return lp; +} + +// --- 1. Unity is exactly one frame per output frame, with no drifting residue. --- +static void testUnityRateFeedsExactlyOneFramePerOutputFrame() { + StretchCursor c; + c.start(100); + const ResolvedLoop lp = noLoop(); + for (std::int64_t i = 0; i < 200000; ++i) { + CHECK(c.due(1.0) == 1); + CHECK(c.next(lp) == 100 + i); + } + // No accumulated debt after 200k frames: the source frame the cursor is about to feed is + // exactly the one an un-stretched integer walk would be at. A residue of even one frame + // over a long note would move the shipped Preserve output. + CHECK(c.frame() == 100 + 200000); +} + +// --- 2. The schedule tracks the rate. --- +static void testTotalConsumedTracksTheRate() { + const ResolvedLoop lp = noLoop(); + // Includes a rate with no exact binary representation, where a naive per-frame rounding + // would drift without bound rather than carrying the residue. + for (double rate : {0.5, 0.75, 1.0, 1.3333333333333333, 2.0, 1.0 / 3.0 + 1.0}) { + StretchCursor c; + c.start(0); + const std::int64_t outFrames = 100000; + for (std::int64_t i = 0; i < outFrames; ++i) { + const std::int64_t due = c.due(rate); + for (std::int64_t k = 0; k < due; ++k) (void)c.next(lp); + } + const double expected = static_cast(outFrames) * clampStretchRate(rate); + CHECK(std::fabs(static_cast(c.frame()) - expected) <= 1.0); + } +} + +// --- 3. The feed count is bounded — the RT-safety argument for a variable-length loop. --- +static void testFeedPerOutputFrameIsBounded() { + const ResolvedLoop lp = noLoop(); + // Drive at, above and around the ceiling; an unclamped rate would run the caller's loop + // for as many iterations as the rate names. + for (double rate : {kStretchRateMax, kStretchRateMax * 100.0, 3.99, 2.5}) { + StretchCursor c; + c.start(0); + std::int64_t worst = 0; + for (std::int64_t i = 0; i < 20000; ++i) { + const std::int64_t due = c.due(rate); + if (due > worst) worst = due; + for (std::int64_t k = 0; k < due; ++k) (void)c.next(lp); + } + CHECK(worst <= kMaxFeedPerFrame); + CHECK(worst >= 1); // and the bound is not vacuous — frames genuinely fell due + } +} + +// --- 4. The clamp. --- +static void testRateClamp() { + CHECK(clampStretchRate(1.0) == 1.0); // exact: the unity read depends on it + CHECK(clampStretchRate(0.5) == 0.5); + CHECK(clampStretchRate(2.0) == 2.0); + CHECK(clampStretchRate(0.001) == kStretchRateMin); + CHECK(clampStretchRate(1000.0) == kStretchRateMax); + // Unusable input plays at speed rather than stalling or quarter-speeding. + CHECK(clampStretchRate(0.0) == 1.0); + CHECK(clampStretchRate(-2.0) == 1.0); + CHECK(clampStretchRate(std::nan("")) == 1.0); + // ...and the cursor honours it rather than looping on the raw value. + StretchCursor c; + c.start(0); + CHECK(c.due(-5.0) == 1); // folded to unity + StretchCursor d; + d.start(0); + CHECK(d.due(50.0) <= kMaxFeedPerFrame); +} + +// --- 5. The loop wraps the SOURCE cursor, at every rate. --- +static void testCursorStaysInsideTheLoopSpan() { + const ResolvedLoop lp = loopSpan(1000, 1040); // a 40-frame loop: rate 4 steps 10% of it + for (double rate : {0.5, 1.0, 2.0, 4.0}) { + StretchCursor c; + c.start(1000); + std::int64_t lowest = 1 << 30, highest = -1; + for (std::int64_t i = 0; i < 50000; ++i) { + const std::int64_t due = c.due(rate); + for (std::int64_t k = 0; k < due; ++k) { + const std::int64_t q = c.next(lp); + if (q < lowest) lowest = q; + if (q > highest) highest = q; + } + } + // Never reads outside the span — the "loop the source, shift the output" contract does + // not weaken under a stretch, because the span is a source-frame fact. + CHECK(lowest >= lp.start); + CHECK(highest < lp.end); + CHECK(highest == lp.end - 1); // and it genuinely covered the span + CHECK(lowest == lp.start); + } + // A cursor started BEYOND the loop end (the start-point-past-the-loop case) is pulled in on + // its first take rather than reading off the end. + StretchCursor c; + c.start(5000); + const std::int64_t q = c.next(lp); + CHECK(q >= lp.start && q < lp.end); +} + +int main() { + testUnityRateFeedsExactlyOneFramePerOutputFrame(); + testTotalConsumedTracksTheRate(); + testFeedPerOutputFrameIsBounded(); + testRateClamp(); + testCursorStaysInsideTheLoopSpan(); + + if (g_fail == 0) { + std::printf("all time_stretch tests passed\n"); + return 0; + } + std::printf("%d time_stretch check(s) failed\n", g_fail); + return 1; +}