Merge pq-w0-fixes: six Q-W0 audit fix-nows + review riders — linked-lag stereo SOLA w/ follower self-heal, prime playable-span bound, provenance cursor hardening, rate-derived gain ramp + fade ceiling; 56/56 green

This commit is contained in:
2026-07-28 19:29:15 -04:00
13 changed files with 495 additions and 69 deletions
+8
View File
@@ -315,6 +315,14 @@ the clean bills) are deliberately absent.
fallback): document-and-defer with the comment amended to name the 44.1 k assumption, if zero
UI-feel change is preferred. (Folding either into Q-W2v instead is *not* recommended — it
would put behavior changes inside a mechanical-split wave; §2.5(8).)
**Recorded deviation (Q-W0 remediation, code review):** T3-03 as implemented resolves
`fadeMaxFrames()` against `liveSampleRate()` (the host/project rate), not the per-file rate
this section's text literally suggests ("the loaded source's rate"). Reviewer verified this
is the more correct choice: no resample path exists anywhere in `src/`, the engine advances
one source frame per host frame, and this matches the time base `paintEnvelopeOverlay`
already uses for the same fades (`totalSeconds = frames / liveSampleRate()`). No further
action — recorded here so the audit text and the shipped behavior don't read as diverged.
- **(e) WAV/RIFF consolidation moment — the one true track disagreement (T2-08 vs T4-23/T4-10).**
T2 prefers the `core/wav` homing moment (the relocation wave); T4 prefers "the wave that opens
`ingest.cpp`" — which does not exist, and T4-10 points back circularly. *Recommend:* record
+37 -11
View File
@@ -2,6 +2,7 @@
#include <cstdio>
#include <cstdlib>
#include <limits>
// provenance implementation — pure, self-contained (no third-party lib, mirror of
// bank_model's hand-rolled encoding discipline).
@@ -60,22 +61,33 @@ public:
bool ok() const { return ok_; }
bool atEnd() const { return pos_ >= s_.size(); }
// Reads one length-prefixed field into `out`. Fails on a missing ':',
// non-numeric length, or a length that runs past the end.
// Reads one length-prefixed field into `out`. Fails on a missing ':', an empty or
// non-numeric length, a length that overflows SIZE_MAX, or a length that runs past
// the end. Hardened form backported from the assignment_request / sample_usage
// siblings (Q-W0 T2-01a): the digit count is capped at 20 (the decimal width of
// SIZE_MAX on a 64-bit host) so a crafted 200-digit length cannot accumulate past
// SIZE_MAX via repeated multiply, and the bounds check is subtraction-first so a
// huge `len` cannot wrap `start + len` past the end test.
bool field(std::string& out) {
if (!ok_) return false;
std::size_t colon = s_.find(':', pos_);
const std::size_t colon = s_.find(':', pos_);
if (colon == std::string::npos) return fail();
// Parse the length digits [pos_, colon).
std::size_t len = 0;
if (colon == pos_) return fail(); // empty length token
// Cap: SIZE_MAX fits in at most 20 decimal digits; a longer run is bogus.
if (colon - pos_ > 20u) return fail();
std::size_t len = 0;
for (std::size_t i = pos_; i < colon; ++i) {
char c = s_[i];
const char c = s_[i];
if (c < '0' || c > '9') return fail();
len = len * 10 + static_cast<std::size_t>(c - '0');
const std::size_t digit = static_cast<std::size_t>(c - '0');
// Overflow guard: if len would exceed SIZE_MAX after multiply+add, fail.
if (len > (std::numeric_limits<std::size_t>::max() - digit) / 10u)
return fail();
len = len * 10u + digit;
}
const std::size_t start = colon + 1;
if (start + len > s_.size()) return fail();
// Subtraction-first form: start + len cannot wrap on a huge len.
if (start > s_.size() || len > s_.size() - start) return fail();
out.assign(s_, start, len);
pos_ = start + len;
return true;
@@ -87,14 +99,20 @@ public:
return toInt(f, out);
}
// A length-prefixed unsigned decimal (the GUID count). Hardened (Q-W0 T2-01a, the
// sample_usage fieldCount pattern): fails on empty, non-digit, a digit run past 20
// (SIZE_MAX's decimal width), or an accumulate that would overflow SIZE_MAX.
bool fieldSizeT(std::size_t& out) {
std::string f;
if (!field(f)) return false;
if (f.empty()) return fail();
if (f.empty() || f.size() > 20u) return fail();
std::size_t v = 0;
for (char c : f) {
for (const char c : f) {
if (c < '0' || c > '9') return fail();
v = v * 10 + static_cast<std::size_t>(c - '0');
const std::size_t digit = static_cast<std::size_t>(c - '0');
if (v > (std::numeric_limits<std::size_t>::max() - digit) / 10u)
return fail();
v = v * 10u + digit;
}
out = v;
return true;
@@ -122,6 +140,9 @@ public:
private:
bool fail() { ok_ = false; return false; }
// TODO(Q-W1): strtol does not check errno/range here, so an out-of-range field narrows
// silently to LONG_MAX (then truncates into `int`) instead of failing parse. Flagged for
// the Q-W1 wire-codec collapse rather than fixed in place.
static bool toInt(const std::string& f, int& out) {
const char* b = f.c_str();
char* end = nullptr;
@@ -196,6 +217,11 @@ std::optional<CaptureRecipe> parseFingerprint(const std::string& fingerprint) {
std::size_t guidCount = 0;
if (!c.fieldSizeT(guidCount)) return std::nullopt;
// Q-W0 T2-01a (the sample_usage count-sanity pattern): each GUID field costs at least
// 2 wire bytes ("0:"), so a count past size/2 is provably bogus — reject BEFORE the
// reserve, so a corrupt/crafted persisted fingerprint can never drive reserve(huge)
// into std::length_error / bad_alloc through the shell.
if (guidCount > fingerprint.size() / 2u + 1u) return std::nullopt;
r.trackGuids.reserve(guidCount);
for (std::size_t i = 0; i < guidCount; ++i) {
std::string g;
+66 -3
View File
@@ -48,6 +48,7 @@ void PitchShifter::configure(std::int64_t windowFrames) {
filled_ = 0;
ratio_ = 1.0;
tailFrozen_ = false;
lastSplice_ = SpliceEvent{};
return;
}
// 2x-window ring: one window of splice-jump span plus search + fade headroom on each side.
@@ -97,6 +98,7 @@ void PitchShifter::reset() {
filled_ = 0;
ratio_ = 1.0;
tailFrozen_ = false;
lastSplice_ = SpliceEvent{};
}
void PitchShifter::freezeTail() {
@@ -146,6 +148,7 @@ void PitchShifter::prime(const AudioSample* src, std::int64_t count) {
fadeLen_ = 0;
filled_ = count;
tailFrozen_ = false; // a fresh note-on always starts with a live writer
lastSplice_ = SpliceEvent{};
// ratio_ deliberately untouched: the voice sets it per frame around the prime.
}
@@ -163,6 +166,7 @@ void PitchShifter::warm() {
fadeLen_ = 0;
filled_ = window_;
tailFrozen_ = false;
lastSplice_ = SpliceEvent{};
}
void PitchShifter::setShiftRatio(double ratio) {
@@ -197,8 +201,9 @@ void PitchShifter::splice(std::int64_t nominalJump, double delay) {
// search (and the +/-1-lag parabolic refinement calls at bestLag ± 1, and the interpolator's
// read-ahead) can touch is delay d + jump + maxLag + 2 (maxLag from the coarse/fine search,
// +1 for the parabola's outer ± 1 probe, +1 for the interpolator's i1 = i0+1 read-ahead),
// so the tight cap is filled_ - d - maxLag_ - 2. The code uses - 1 here — one sample of
// conservative margin, never out-of-range. In steady state (filled_ == ringLen_) this is
// so the tight cap is filled_ - d - maxLag_ - 2. The code uses - 1 here — one sample LOOSER
// than that derived cap (not extra margin); ring indexing wraps via modulo everywhere, so
// this never runs off the physical ring_ array. In steady state (filled_ == ringLen_) this is
// > window_ and the nominal jump is untouched; near a primed onset it shrinks the jump to
// what real history exists (still many source periods with a full-window prime). The floor of
// 1 is only reachable on the documented degenerate reset-without-prime path — garbage-tolerant.
@@ -311,11 +316,42 @@ void PitchShifter::splice(std::int64_t nominalJump, double delay) {
}
fading_ = true;
fadePos_ = 0;
// Record the decision for a linked follower channel (T1-01): the follower applies this
// verbatim so both channels share one lag and one splice schedule.
lastSplice_ = SpliceEvent{true, jump, bestLag, frac, fadeLen_};
}
AudioSample PitchShifter::process(AudioSample in) {
void PitchShifter::applySplice(const SpliceEvent& ev) {
// Follower half of the T1-01 linked lag: relocate + fade with the master's decision, no
// correlation search of our own. The master's jump was clamped against ITS filled_/delay,
// which match ours by the lockstep contract (identical configure/prime/ratio history);
// the fade length likewise derives only from shared geometry + ratio.
posB_ = posA_;
double p = posA_ - static_cast<double>(ev.jump) + static_cast<double>(ev.lag) + ev.frac;
const double len = static_cast<double>(ringLen_);
while (p < 0.0) p += len;
while (p >= len) p -= len;
posA_ = p;
fadeLen_ = std::max<std::int64_t>(1, ev.fadeLen);
fading_ = true;
fadePos_ = 0;
lastSplice_ = ev; // observable mirror (tests assert follower == master per frame)
}
AudioSample PitchShifter::process(AudioSample in) { return processImpl(in, nullptr); }
AudioSample PitchShifter::processLinked(AudioSample in, const SpliceEvent& master) {
return processImpl(in, &master);
}
AudioSample PitchShifter::processImpl(AudioSample in, const SpliceEvent* linked) {
if (window_ <= 1) return in; // pass-through (unconfigured / degenerate)
// Copy the linked decision BEFORE clearing lastSplice_ (guards a self-aliased pointer;
// 5 plain fields, negligible on the RT path).
const SpliceEvent linkedEv = linked != nullptr ? *linked : SpliceEvent{};
lastSplice_ = SpliceEvent{}; // cleared every frame; set again if this frame splices
// 1. Write the incoming sample at the write head (source rate). One more slot of the
// ring now holds valid history (capped at the ring length once it has wrapped).
// TAIL-FROZEN (GA3): the source is exhausted — `in` is padding, not stream. Write
@@ -335,6 +371,33 @@ AudioSample PitchShifter::process(AudioSample in) {
const double gNew = 0.5 * (1.0 - std::cos(kPi * t));
out = gNew * out + (1.0 - gNew) * readTap(posB_);
if (++fadePos_ >= fadeLen_) fading_ = false;
} else if (linked != nullptr) {
// 3a. FOLLOWER (T1-01): no trigger test, no search — splice exactly when and how the
// master channel did this frame. Lockstep state means our own trigger would have
// fired on the same frame; applying the master's decision keeps the two rings
// sample-aligned (one shared lag, one shared schedule).
if (linkedEv.fired) {
applySplice(linkedEv);
} else {
// Self-healing fallback (review rider): the master not firing normally means this
// channel's own trigger wouldn't fire either (lockstep). But if the processor ever
// renders a mono block mid-note, this follower channel is skipped for that block
// while the master keeps advancing — its writePos_/filled_ falls behind and, with
// only the `if (linkedEv.fired)` path above, could never resync. So check this
// follower's OWN tap distance against the safe band and splice via its own search
// when it has left [dLow_, dHigh_], exactly as the master would. Reuses splice() —
// no allocation, no new RT cost. In the normal (non-mono-block) case this branch
// never triggers: the master's trigger fires first and this whole `if` is false.
double d = static_cast<double>(writePos_) - posA_;
const double len = static_cast<double>(ringLen_);
while (d < 0.0) d += len;
while (d >= len) d -= len;
if (d <= static_cast<double>(dLow_)) {
splice(+window_, d);
} else if (d >= static_cast<double>(dHigh_)) {
splice(-window_, d);
}
}
} else {
// 3. Splice scheduling: relocate when the active tap's delay leaves the safe band.
// Up-shifts (ratio > 1) drain the delay toward 0 -> jump one window OLDER; down-
+49 -7
View File
@@ -70,9 +70,25 @@
namespace reasampler {
// The splice decision made by the most recent process()/processLinked() call — the LINKED-LAG
// stereo contract (Q-W0 T1-01). A stereo voice runs channel 0 as the MASTER (full correlation
// search) and channel 1 as the FOLLOWER: after the master's process() for a frame, the caller
// passes master.lastSplice() to the follower's processLinked() for the SAME frame, and the
// follower applies exactly this decision instead of running its own search. Both channels
// therefore share one lag and one splice schedule (standard stereo SOLA) — per-channel
// independent searches re-drew an inter-channel offset of up to +/-maxLag at every splice:
// stereo image wander at the splice cadence plus comb coloration on any mono sum.
struct SpliceEvent {
bool fired = false; // a splice was scheduled on this frame
std::int64_t jump = 0; // the CLAMPED nominal jump actually applied (signed)
std::int64_t lag = 0; // correlation best integer lag
double frac = 0.0; // parabolic sub-sample refinement, [-0.5, 0.5]
std::int64_t fadeLen = 0; // live (ratio-scaled) crossfade length chosen
};
// A per-channel time-domain splice-aligned pitch shifter. One instance transposes ONE channel;
// a stereo voice owns two — the algorithm is per-sample and channel-count agnostic, matching
// the S7 "one read head, per-channel value" idiom of the core.
// a stereo voice owns two, LINKED: channel 0 is the master, channel 1 follows its splice
// decisions via processLinked() (see SpliceEvent above) so the two rings stay sample-aligned.
//
// The default-constructed shifter is INERT: with no configure() it passes input through
// unchanged (shift ratio 1.0, empty ring), so a Varispeed voice that never touches it is
@@ -91,10 +107,13 @@ public:
// the tap on src[0] (delay == count, mid safe band at count == window()). The caller then
// feeds process() the stream CONTINUING at src[count]. Output frame 0 is src[0]: ZERO
// structural latency at every ratio, and splices always have `count` frames of real
// history to land in — the GA2 onset-gap fix. `count` is clamped to [0, window()]; pass
// the full window (pad the tail with silence yourself if the source is shorter — trailing
// silence IS the true stream there). RT-safe: bounded copy into the pre-sized ring, no
// allocation. No-op when unconfigured. The current shift ratio is left untouched.
// history to land in — the GA2 onset-gap fix. `count` is clamped to [0, window()].
// When the PLAYABLE source is shorter than one window, prime only the real span and call
// freezeTail() immediately after (Q-W0 T1-03): the GA3 machinery then recycles the real
// short tail. Do NOT pad with silence and declare it valid — padded zeros inside the ring
// are splice targets, re-creating the pre-GA2 burst/gap onset on sub-window material.
// RT-safe: bounded copy into the pre-sized ring, no allocation. No-op when unconfigured.
// The current shift ratio is left untouched.
void prime(const AudioSample* src, std::int64_t count);
// prime()-with-silence: zero the ring, park the tap one window behind the writer, and
@@ -118,6 +137,20 @@ public:
// active tap leaves its safe delay band, a correlation-aligned splice is scheduled.
AudioSample process(AudioSample in);
// FOLLOWER-mode process (Q-W0 T1-01, the stereo linked lag): identical to process()
// except the splice decision is NOT computed here — when `master.fired` is true this
// frame splices with exactly the master's jump/lag/frac/fadeLen; otherwise no splice is
// considered. The caller must process the master channel FIRST each frame and pass its
// lastSplice() here, with both shifters configured/primed/ratio'd identically — their
// ring state then advances in lockstep, so the follower's own trigger would have fired
// on the same frame anyway; skipping its search only removes the second correlation
// burst (strictly cheaper, never costlier). RT-safe: same guarantees as process().
AudioSample processLinked(AudioSample in, const SpliceEvent& master);
// The splice decision made by the most recent process()/processLinked() call (fired ==
// false when that frame spliced nothing). Feed to a follower channel's processLinked().
const SpliceEvent& lastSplice() const { return lastSplice_; }
// TAIL WIND-DOWN (GA3, 2026-07). Call when the SOURCE STREAM IS EXHAUSTED — no real frame
// remains to feed process(). Freezes the WRITE head: subsequent process() calls ignore
// their input and write nothing, but read, splice, and crossfade exactly as before over
@@ -150,8 +183,15 @@ private:
double readTap(double pos) const; // fractional ring read, linear interp
// Relocate the active tap by ~`nominalJump` frames of added delay (clamped to the filled
// span for up-jumps) and start the crossfade. `delay` is the tap's current delay behind
// the writer (the caller just computed it for the trigger test).
// the writer (the caller just computed it for the trigger test). Records the decision in
// lastSplice_ for a linked follower channel.
void splice(std::int64_t nominalJump, double delay);
// Apply a master channel's already-computed splice decision verbatim (no search) —
// the follower half of the T1-01 linked-lag contract. Mirrors it into lastSplice_.
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);
std::vector<AudioSample> ring_; // delay line, length `ringLen_` == 2 * window_
std::int64_t window_ = 0; // nominal splice jump in frames; <= 1 = pass-through
@@ -176,6 +216,8 @@ private:
// its up-jump to this so no splice lands in unwritten
// silence — the GA2 onset-gap fix.
double ratio_ = 1.0; // current shift ratio (>0)
SpliceEvent lastSplice_{}; // decision of the most recent process*() frame (T1-01):
// cleared at the top of every frame, set on a splice
bool tailFrozen_ = false; // GA3 wind-down: writer frozen (source exhausted); the tap
// recycles the ring's frozen real tail, splices still
// aligned. With the writer parked, a tap drains toward it
+33 -8
View File
@@ -389,10 +389,13 @@ namespace {
// engine-free and maps only 0..1). WALL-CLOCK time sliders (AHDSR A/H/D/R, pitch env A/D) span
// [0, kEnvTimeMaxSeconds] SECONDS — rate-free, exactly what the zone stores; the keymap build
// resolves seconds->frames at the live rate. SOURCE-timeline fade sliders (Trigger fade-in/out)
// span [0, kFadeMaxFrames] SOURCE frames (a source-timeline quantity, PLAN.md §S15 — never a
// wall-clock second). Build-time residual — one place to retune; not persisted.
// STORE source frames (PLAN.md §S15 — never a wall-clock second; the storage domain is
// settled-correct and unchanged), but the knob's FULL-SCALE THROW is a wall-clock intent —
// kFadeMaxSeconds resolved against the live rate at use (fadeMaxFrames(), Q-W0 T3-03; the
// prior 88200-frame constant baked 2 s x 44.1 kHz into src/, against the no-hardcoded-rate
// ruling). Build-time residual — one place to retune; not persisted.
constexpr double kEnvTimeMaxSeconds = 2.0; // AHDSR A/H/D/R + pitch A/D throw ceiling (seconds)
constexpr double kFadeMaxFrames = 88200.0; // Trigger fade throw ceiling (source frames)
constexpr double kFadeMaxSeconds = 2.0; // Trigger fade throw ceiling (wall-clock)
constexpr double kPitchDepthMaxSemis = 24.0; // AD pitch depth throw: +/-24 st, centered
constexpr double kKeyTrackMax = 2.0; // S-VIEW-6 key-track slider ceiling (0..200%)
@@ -401,10 +404,15 @@ double clamp01(double v) { return v < 0.0 ? 0.0 : (v > 1.0 ? 1.0 : v); }
double ReaSamplerEditor::controlValue(int id, const ZonePlaySeconds& play) const {
// Wall-clock seconds -> normalized over the seconds ceiling; source frames -> normalized over
// the frames ceiling. Two domains, kept explicit so neither leaks a rate.
// the rate-resolved frames ceiling (T3-03). Two domains, kept explicit so neither leaks a rate.
// A stored fade exceeding fadeMaxFrames() at the current host rate reads as norm 1.0 (clamp01
// pins it) and gets rewritten down on the next knob touch — deliberate, matching the old
// fixed-ceiling clamp behavior in kind, just rate-dependent now instead of fixed at 88200.
const double fadeMax = fadeMaxFrames();
const auto secToNorm = [](double s) { return clamp01(s / kEnvTimeMaxSeconds); };
const auto framesToNorm = [](std::int64_t f) {
return clamp01(static_cast<double>(f) / kFadeMaxFrames);
const auto framesToNorm = [fadeMax](std::int64_t f) {
// Ceiling unavailable (rate not yet known): inert until fadeMaxFrames() resolves.
return fadeMax > 0.0 ? clamp01(static_cast<double>(f) / fadeMax) : 0.0;
};
switch (static_cast<ParamControl>(id)) {
case ParamControl::kPlayMode: return play.playMode == PlayMode::Trigger ? 1.0 : 0.0;
@@ -429,9 +437,12 @@ double ReaSamplerEditor::controlValue(int id, const ZonePlaySeconds& play) const
void ReaSamplerEditor::applyControl(int id, ZonePlaySeconds& play, double value,
int segment) const {
const double fadeMax = fadeMaxFrames(); // T3-03: rate-resolved knob full-scale
const auto normToSec = [](double v) { return clamp01(v) * kEnvTimeMaxSeconds; };
const auto normToFrames = [](double v) {
return static_cast<std::int64_t>(clamp01(v) * kFadeMaxFrames + 0.5);
const auto normToFrames = [fadeMax](double v) -> std::int64_t {
// Ceiling unavailable (rate not yet known): inert until fadeMaxFrames() resolves.
if (fadeMax <= 0.0) return 0;
return static_cast<std::int64_t>(clamp01(v) * fadeMax + 0.5);
};
switch (static_cast<ParamControl>(id)) {
case ParamControl::kPlayMode:
@@ -467,6 +478,20 @@ double ReaSamplerEditor::liveSampleRate() const {
return processor_ ? processor_->sampleRate() : 0.0;
}
double ReaSamplerEditor::fadeMaxFrames() const {
// T3-03: the Trigger-fade knob's full-scale throw is kFadeMaxSeconds (2 s wall-clock)
// resolved against the live rate — the SAME time base the envelope overlay already uses
// to place these source-frame fades on screen (totalSeconds = frames / liveSampleRate()),
// and the rate captures are made at (the capture path renders at the project rate).
// Pre-setupProcessing the rate is still 0: rather than substitute a literal rate (the
// exact residue T3-03 removed), bail the same way paintEnvelopeOverlay does (~line 1396) —
// callers treat a <= 0 return as "ceiling unavailable yet" and degrade the knob to inert
// rather than guess a rate. Storage stays SOURCE FRAMES — this resolves the UI ceiling only.
const double rate = liveSampleRate();
if (rate <= 0.0) return 0.0;
return kFadeMaxSeconds * rate;
}
double ReaSamplerEditor::previewVelocity01() const {
if (!processor_) return static_cast<double>(kPreviewVelocityDefault) / 127.0;
return static_cast<double>(processor_->previewVelocity()) / 127.0;
+5
View File
@@ -318,6 +318,11 @@ private:
// into the control's stored domain) or a toggle's `segment` (0/1). Mutates `play` in place.
void applyControl(int id, ZonePlaySeconds& play, double value, int segment) const;
// The Trigger fade-in/out knob full-scale, in SOURCE frames: kFadeMaxSeconds (2 s
// wall-clock) resolved against the live rate at use (Q-W0 T3-03 — never a baked-in
// rate). 44.1 kHz fallback before setupProcessing has run. Storage stays frames.
double fadeMaxFrames() const;
// --- S-VIEW-3 envelope overlay seam (frames <-> fraction converter) ----------
//
// envelope_overlay's AmpEnvelope is a DERIVED VIEW, not a TriggerParams copy: it stores the
+24 -12
View File
@@ -43,12 +43,14 @@ namespace {
// FIXED so raising the voice count never multiplies shifter CPU past the profiled budget.
constexpr std::size_t kPreserveVoiceCap = 8;
// FB1 post-mixer gain ramp rate (per sample). gainCurrent_ converges to masterGain_ at this
// linear step; it ramps from 0 to unity (or vice versa) in ~20 ms at 48 kHz. The early-out
// (|current - target| < threshold) snaps to the target and avoids the ramp loop on idle blocks.
// kGainRampSnap is the threshold below which we snap to the target (avoids long sub-LSB creep).
constexpr float kGainRampRate = 1.0f / 960.0f; // 960 samples @ 48 kHz ≈ 20 ms
constexpr float kGainRampSnap = kGainRampRate * 0.5f;
// FB1 post-mixer gain ramp TIME (wall-clock). gainCurrent_ converges to masterGain_ by a
// linear per-sample step derived from this at setupProcessing (gainRampStep_ =
// 1 / (kGainRampSeconds * sampleRate_)) — the kPreserveWindowMs pattern, per the standing
// no-hardcoded-rate ruling (Q-W0 T3-01; the prior constant baked 20 ms x 48 kHz in as
// 1/960, silently shortening the ramp at higher host rates). A full 0-to-unity ramp is
// ~20 ms at EVERY host rate; the snap threshold (half a step, below which gainCurrent_
// jumps to the target) avoids long sub-LSB creep and the ramp loop on idle blocks.
constexpr double kGainRampSeconds = 0.020;
// pS-usage: mint a fresh publish identity — 32 lowercase hex chars from the OS entropy
// source. Used for BOTH the persisted per-instance key guid (instanceGuid_) and the
@@ -203,6 +205,12 @@ tresult PLUGIN_API ReaSamplerProcessor::setActive(TBool state) {
tresult PLUGIN_API ReaSamplerProcessor::setupProcessing(ProcessSetup& setup) {
sampleRate_ = setup.sampleRate;
maxBlockSize_ = setup.maxSamplesPerBlock;
// T3-01: resolve the FB1 gain-ramp step against the live host rate (20 ms wall-clock at
// every rate). At 48 kHz this is exactly the former 1/960 constant. Written here (host
// guarantees setupProcessing never overlaps process), read on the audio thread only.
if (sampleRate_ > 0.0) {
gainRampStep_ = static_cast<float>(1.0 / (kGainRampSeconds * sampleRate_));
}
return SingleComponentEffect::setupProcessing(setup);
}
@@ -1072,13 +1080,15 @@ tresult PLUGIN_API ReaSamplerProcessor::process(ProcessData& data) {
// actual output. Branch-free inner loop; early-out when already at target. RT-safe.
{
const float gTarget = masterGain_.load(std::memory_order_relaxed);
const float gStep = gainRampStep_; // T3-01: rate-derived per-sample step
const float gSnap = 0.5f * gStep;
const float diff = gTarget - gainCurrent_;
if (diff < -kGainRampSnap || diff > kGainRampSnap) {
if (diff < -gSnap || diff > gSnap) {
// Ramp toward target: step per sample, then apply the per-sample gain.
for (int32 i = 0; i < frames; ++i) {
const float d = gTarget - gainCurrent_;
if (d > kGainRampRate) gainCurrent_ += kGainRampRate;
else if (d < -kGainRampRate) gainCurrent_ -= kGainRampRate;
if (d > gStep) gainCurrent_ += gStep;
else if (d < -gStep) gainCurrent_ -= gStep;
else gainCurrent_ = gTarget;
ch0[i] *= gainCurrent_;
ch1[i] *= gainCurrent_;
@@ -1115,12 +1125,14 @@ tresult PLUGIN_API ReaSamplerProcessor::process(ProcessData& data) {
// post-sum, pre-peak/replicate, per-sample gainCurrent_ ramp toward target, RT-safe.
{
const float gTarget = masterGain_.load(std::memory_order_relaxed);
const float gStep = gainRampStep_; // T3-01: rate-derived per-sample step
const float gSnap = 0.5f * gStep;
const float diff = gTarget - gainCurrent_;
if (diff < -kGainRampSnap || diff > kGainRampSnap) {
if (diff < -gSnap || diff > gSnap) {
for (int32 i = 0; i < frames; ++i) {
const float d = gTarget - gainCurrent_;
if (d > kGainRampRate) gainCurrent_ += kGainRampRate;
else if (d < -kGainRampRate) gainCurrent_ -= kGainRampRate;
if (d > gStep) gainCurrent_ += gStep;
else if (d < -gStep) gainCurrent_ -= gStep;
else gainCurrent_ = gTarget;
ch0[i] *= gainCurrent_;
}
+7 -2
View File
@@ -454,13 +454,18 @@ private:
// FB1 post-mixer master gain (LINEAR; persisted in component state v8). A lock-free
// atomic — the target the UI thread writes; the audio thread ramps gainCurrent_ toward
// it per-sample each block (linear interpolation, ~20 ms at 48 kHz / 256-frame block)
// it per-sample each block (linear interpolation, ~20 ms wall-clock at every host rate)
// so sudden knob moves produce no zipper noise and the true-zero bottom causes no click.
std::atomic<float> masterGain_{1.0f};
// The audio-thread running gain value: tracks masterGain_ across blocks, stepping at
// most kGainRampRate per sample toward the target. Starts at unity (pre-FB1 default).
// most gainRampStep_ per sample toward the target. Starts at unity (pre-FB1 default).
// Written and read exclusively on the audio thread — no atomics needed.
float gainCurrent_ = 1.0f;
// T3-01: the per-sample ramp step, derived from kGainRampSeconds (20 ms wall-clock)
// against the live host rate in setupProcessing — never a baked-in rate. The default is
// the 48 kHz value so behavior before the first setupProcessing is unchanged. Written in
// setupProcessing (host-serialized against process), read on the audio thread.
float gainRampStep_ = 1.0f / 960.0f;
// --- S-VIEW-4 preview-trigger mailbox (off-thread -> audio thread, lock-free) ---------
// The editor's preview-trigger button posts a note-on/off request from the UI thread; process()
+49 -23
View File
@@ -297,8 +297,7 @@ void Voice::start(int note, int velocity, const SampleData& sample, int rootNote
// Any in-flight ramp is superseded: pending re-derives from the reference, which already
// includes the running declick's contribution via lastOut (it tracks post-declick output).
declickActive_ = false;
declickL_ = 0.0;
declickR_ = 0.0;
declickWeight_ = 0.0;
active_ = true;
releasing_ = false;
@@ -378,25 +377,49 @@ void Voice::start(int note, int velocity, const SampleData& sample, int rootNote
const SampleLoop& loop = sample.loop;
const std::int64_t loopLen = loopWrap ? (loop.end - loop.start) : 0;
const bool stereoSample = sample.channelCount() == 2 && shiftR_.configured();
// Q-W0 T1-03: the prime may only carry PLAYABLE source. The per-frame feed stops at
// feedBound (playEnd_ for a bounded Trigger span, the sample end for Gate) and
// freezes the writer there (GA3) — but the prime used to pull a FULL window bounded
// only by frameCount: a Trigger ring held real PCM past the user's chosen stop (an
// up-shifted tap could play it, transposed, before the voice freed), and a
// shorter-than-window sample got zero padding declared as valid history (splices
// landing in silence — the pre-GA2 burst/gap onset, re-entered for sub-window
// material). So bound the prime by the same playable span and, when that span is
// shorter than a window, freeze the tail IMMEDIATELY after the prime — the GA3
// machinery then recycles the real short tail, its designed behavior. The sustain-
// loop path is unbounded by construction (the wrap keeps q inside the loop forever).
const std::int64_t primeBound =
(playMode_ == PlayMode::Trigger && playEnd_ > 0 && playEnd_ < frameCount)
? playEnd_ : frameCount;
const std::int64_t primeCount =
loopWrap ? w : std::min<std::int64_t>(w, primeBound - start);
// Both channels walk identical SOURCE positions (the walk depends only on loop geometry,
// not on channel PCM values) — compute `p` once for channel 0, reuse for channel 1.
std::int64_t p = start;
for (int ch = 0; ch < (stereoSample ? 2 : 1); ++ch) {
const std::vector<AudioSample>& pcmCh = ch == 0 ? sample.frames : sample.framesR;
std::int64_t q = start;
for (std::int64_t i = 0; i < w; ++i) {
for (std::int64_t i = 0; i < primeCount; ++i) {
if (loopWrap) {
while (q >= loop.end) q -= loopLen;
}
// q < frameCount holds by construction on the non-loop path (primeCount is
// bounded); the guard stays as a belt for the loop-wrap walk.
primeBuf_[static_cast<std::size_t>(i)] =
(q < frameCount) ? pcmCh[static_cast<std::size_t>(q)] : 0.0f;
++q;
}
(ch == 0 ? shiftL_ : shiftR_).prime(primeBuf_.data(), w);
(ch == 0 ? shiftL_ : shiftR_).prime(primeBuf_.data(), primeCount);
if (ch == 0) p = q; // capture the end position once from channel 0's walk
}
// Per-frame feed continues at `p`, exactly one window ahead of readPos_.
// Per-frame feed continues at `p` (== the feed bound when the prime exhausted the
// playable span — advanceFrame's own exhaustion test then holds from frame 0).
feedPos_ = p;
if (!loopWrap && primeCount < w) {
// Sub-window playable span: the source is ALREADY exhausted at prime time.
shiftL_.freezeTail();
if (stereoSample) shiftR_.freezeTail();
}
}
ratio_ = baseRatio_; // seeded; advanceFrame recomputes per frame under the active engine.
}
@@ -457,8 +480,7 @@ void Voice::seedDeclick(double newOutL, double newOutR) {
// gone: the blend formula keeps every output within max(|ref|,|outₙ|) by construction.
(void)newOutL; (void)newOutR; // consumed only for the floor guard below
declickPending_ = false;
declickL_ = 1.0;
declickR_ = 1.0;
declickWeight_ = 1.0; // ONE weight for both channels (T1-09: the per-R copy was dead state)
// The reference is already clamped to ±1.0 at start() (lines in start(): the ±1 clamp
// on lastOutL_/R_ before storing into declickRefL_/R_). No secondary clamp needed here.
// Activate only when the ref itself is above the floor — if ref ≈ 0 there is nothing to blend.
@@ -512,11 +534,10 @@ AudioSample Voice::advanceFrame(bool stereo, AudioSample& outR) {
if (declickActive_) {
// Bounded blend at silence: outCurrent == 0, so the blend is w*(ref 0) == w*ref.
// The weight decays by kDeclickDecay each frame, floor-checked on the weight itself.
const double l = declickL_ * declickRefL_;
const double r = declickL_ * declickRefR_; // same weight for both channels
declickL_ *= kDeclickDecay;
declickR_ *= kDeclickDecay;
if (declickL_ < kDeclickFloor && declickL_ > -kDeclickFloor) {
const double l = declickWeight_ * declickRefL_;
const double r = declickWeight_ * declickRefR_; // same weight for both channels
declickWeight_ *= kDeclickDecay;
if (declickWeight_ < kDeclickFloor && declickWeight_ > -kDeclickFloor) {
declickActive_ = false;
active_ = false;
}
@@ -578,15 +599,21 @@ AudioSample Voice::advanceFrame(bool stereo, AudioSample& outR) {
outL = shiftedL * gain;
if (stereo) {
if (haveR && shiftR_.configured()) {
// Genuine stereo: an independent shifter transposes channel 1. Each shifter is
// process()'d EXACTLY ONCE per output frame (never twice — that would advance its
// heads twice and corrupt the OLA state). 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 into this one.
// Genuine stereo (Q-W0 T1-01, 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). 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 ? pcmR[static_cast<std::size_t>(feedPos_)] : 0.0f;
shiftR_.setShiftRatio(shift);
outRlocal = static_cast<double>(shiftR_.process(feedR)) * gain;
outRlocal =
static_cast<double>(shiftR_.processLinked(feedR, shiftL_.lastSplice())) *
gain;
} 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 shiftL_.process again
@@ -636,13 +663,12 @@ AudioSample Voice::advanceFrame(bool stereo, AudioSample& outR) {
// Inactive (the common case) costs one branch; the blend itself costs one extra subtract.
if (declickPending_) seedDeclick(outL, stereo ? outRlocal : outL);
if (declickActive_) {
const double addL = declickL_ * (declickRefL_ - outL);
const double addR = declickL_ * (declickRefR_ - (stereo ? outRlocal : outL));
const double addL = declickWeight_ * (declickRefL_ - outL);
const double addR = declickWeight_ * (declickRefR_ - (stereo ? outRlocal : outL));
outL += addL;
if (stereo) outRlocal += addR;
declickL_ *= kDeclickDecay;
declickR_ *= kDeclickDecay; // kept in sync (mirrors L — both channels share one weight)
if (declickL_ < kDeclickFloor && declickL_ > -kDeclickFloor) {
declickWeight_ *= kDeclickDecay; // one shared weight — both channels decay together
if (declickWeight_ < kDeclickFloor && declickWeight_ > -kDeclickFloor) {
declickActive_ = false;
}
}
+4 -3
View File
@@ -582,7 +582,9 @@ private:
// recent rendered output (post-gain, incl. any running declick). A takeover/steal start()
// records them as declickRef{L,R}_ (the clamped pre-cut reference) and sets declickPending_;
// the first frame rendered after the restart calls seedDeclick to arm the BOUNDED BLEND:
// outₙ = outₙ*(1w) + ref*w where w = declickL_/R_ starts at 1.0 and decays by
// outₙ = outₙ*(1w) + ref*w where w = declickWeight_ (ONE weight, deliberately shared
// by both channels so L/R can never diverge — Q-W0 T1-09 removed the dead per-R copy)
// starts at 1.0 and decays by
// kDeclickDecay each frame. This is algebraically `outₙ + w*(ref outₙ)`, so the
// boundary frame (w=1) is exactly `ref` and every subsequent output is bounded by
// max(|ref|, |outₙ|) — mid-ramp overshoot is impossible regardless of outₙ rising.
@@ -595,8 +597,7 @@ private:
bool declickActive_ = false;
double declickRefL_ = 0.0; // clamped pre-cut reference (bounded blend target)
double declickRefR_ = 0.0;
double declickL_ = 0.0; // blend weight w; 1.0 on seed, decays by kDeclickDecay/frame
double declickR_ = 0.0;
double declickWeight_ = 0.0; // blend weight w; 1.0 on seed, decays by kDeclickDecay/frame
double lastOutL_ = 0.0;
double lastOutR_ = 0.0;
+90
View File
@@ -23,6 +23,9 @@
// 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.
// 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/vst/pitch_shift.h"
@@ -491,6 +494,92 @@ static void testFreezeTailContinuousTone() {
}
}
// --- 8. Stereo linked lag (Q-W0 T1-01): a follower channel driven via processLinked()
// applies EXACTLY the master's splice decision — same firing frame, same jump, same
// lag, same sub-sample frac, same fade length — so a stereo pair shares ONE splice
// schedule (no inter-channel offset re-drawn per splice: the pre-fix image-wander /
// mono-sum-combing mechanism). The divergence witness: an INDEPENDENT shifter fed the
// follower's content picks a different lag on the same schedule, proving the mirror
// assertion is not vacuous (the two channels' contents genuinely disagree on the best
// alignment). A third shifter (`mirror`), primed with the SAME content as the master
// and driven via processLinked() with the master's own decisions, must reproduce the
// master's output BIT-IDENTICALLY every frame — this is the review-rider strengthening:
// the `ef == em` mirror check above only proves lastSplice_ was copied verbatim (which
// applySplice() always does), not that applySplice() actually reproduces splice()'s
// effect on posA_/fadeLen_/audio output; a same-content bit-identical check catches a
// real divergence there (e.g. an asymmetry between applySplice()'s unconditional
// `max(1, ev.fadeLen)` and splice()'s own fadeLen_ assignment). This driven-every-frame
// setup keeps both master and follower in lockstep the whole run (posA_/writePos_ stay
// identical since jumps are geometric, not content-dependent), so it exercises
// applySplice() on every splice — never the Q-W0 remediation self-healing fallback
// (own-search splice on a stale follower), which only fires when a follower has been
// skipped a block relative to the master (mono-render-block starvation). ---
static void testStereoLinkedLagSharedSchedule() {
const std::int64_t w = 2205; // the product window
const std::size_t n = 40000; // ~17 splice cycles at ratio 2
// Decorrelated "stereo" content: two different non-integer-period tones, so each
// channel's own correlation optimum lands on a different lag.
const double fL = 1.0 / 196.37;
const double fR = 1.0 / 123.13;
std::vector<AudioSample> srcL(n + static_cast<std::size_t>(w));
std::vector<AudioSample> srcR(n + static_cast<std::size_t>(w));
for (std::size_t i = 0; i < srcL.size(); ++i) {
srcL[i] = static_cast<AudioSample>(std::sin(2.0 * kPi * fL * static_cast<double>(i)));
srcR[i] = static_cast<AudioSample>(std::sin(2.0 * kPi * fR * static_cast<double>(i)));
}
PitchShifter master, follower, independent, mirror;
master.configure(w);
follower.configure(w);
independent.configure(w);
mirror.configure(w);
master.prime(srcL.data(), w);
follower.prime(srcR.data(), w); // linked: R content, master's decisions
independent.prime(srcR.data(), w); // control: R content, OWN search (pre-fix behavior)
mirror.prime(srcL.data(), w); // SAME content as master: bit-identical witness
master.setShiftRatio(2.0);
follower.setShiftRatio(2.0);
independent.setShiftRatio(2.0);
mirror.setShiftRatio(2.0);
int spliceCount = 0;
bool followerDiverged = false;
bool independentDiverged = false;
bool mirrorDiverged = false;
for (std::size_t i = 0; i < n; ++i) {
const std::size_t si = i + static_cast<std::size_t>(w);
const AudioSample oM = master.process(srcL[si]);
const SpliceEvent& em = master.lastSplice();
const AudioSample oR = follower.processLinked(srcR[si], em);
CHECK(std::isfinite(oR));
// The follower mirrors the master's decision EXACTLY, every frame (fired == false
// frames included). In this driven-every-frame lockstep run the follower never falls
// behind, so it never reaches the Q-W0 self-healing fallback — every splice here goes
// through applySplice(), same as the mirror check below.
const SpliceEvent& ef = follower.lastSplice();
if (ef.fired != em.fired || ef.jump != em.jump || ef.lag != em.lag ||
ef.frac != em.frac || ef.fadeLen != em.fadeLen) {
followerDiverged = true;
}
if (em.fired) ++spliceCount;
// The control: same content as the follower, own search. Its decision differing
// from the master's proves the mirror assertion above is load-bearing.
(void)independent.process(srcR[si]);
const SpliceEvent& ei = independent.lastSplice();
if (ei.fired != em.fired || ei.lag != em.lag || ei.frac != em.frac) {
independentDiverged = true;
}
// The bit-identical witness: same content as the master, master's decisions applied
// via applySplice() instead of computed via splice() — the two code paths must produce
// the exact same sample stream.
const AudioSample oMirror = mirror.processLinked(srcL[si], em);
if (oMirror != oM) mirrorDiverged = true;
}
CHECK(spliceCount >= 3); // the run actually exercised several splices
CHECK(!followerDiverged); // linked lag: one decision, one schedule, both channels
CHECK(independentDiverged); // non-tautology witness: unlinked channels DO disagree
CHECK(!mirrorDiverged); // applySplice() reproduces splice() bit-identically
}
int main() {
testDurationInvariance();
testUnityRoughlyReproduces();
@@ -499,6 +588,7 @@ int main() {
testRepitchSpectralPurityAndOnset();
testUnityBitExactAndLatency();
testFreezeTailContinuousTone();
testStereoLinkedLagSharedSchedule();
if (g_fail == 0) {
std::printf("all pitch_shift tests passed\n");
+56
View File
@@ -7,6 +7,8 @@
// track GUIDs, FX-chain identity) -> a MISMATCH (different string / recipe).
// * fxChainIdentity fold: order-sensitive, field-injection-proof, empty-stable.
// * parse of malformed / wrong-version / truncated input -> nullopt (graceful).
// * hardened wire cursor (Q-W0 T2-01a): hostile digit-run lengths, wrap-magnitude
// lengths, and huge GUID counts -> nullopt with no overflow and no over-allocation.
// * parent-detection decision: positive, negative, ambiguous, empty, and the
// edge where a source file is not in the bank (missing-from-bank).
//
@@ -190,6 +192,58 @@ static void testMalformedFingerprint() {
"1:0" "0:").has_value());
}
// --- Q-W0 T2-01a: hardened wire cursor (backported from assignment_request /
// sample_usage) — corrupt or crafted persisted fingerprints must fail the parse
// cleanly (nullopt), never wrap an integer, never throw, never over-allocate. ----
// Mirrors buildFingerprint's field order with benign values, except the GUID-count
// field carries caller-supplied raw text — the attack surface under test.
static void putF(std::string& out, const std::string& f) {
out += std::to_string(f.size());
out += ':';
out += f;
}
static std::string forgedFingerprint(const std::string& guidCountText) {
std::string out = "rsprov1";
putF(out, "0"); // scope = Item
putF(out, "0"); // sourceMode
putF(out, "0"); // startSeconds
putF(out, "1"); // endSeconds
putF(out, "0"); // tailMode
putF(out, "0"); // tailMs
putF(out, "48000"); // sampleRate
putF(out, "2"); // channelCount
putF(out, guidCountText); // GUID count (no GUID fields follow)
putF(out, ""); // fxChainIdentity (empty)
return out;
}
static void testHardenedCursorRejectsHostileLengths() {
// A 200-digit length run: pre-hardening the accumulate wrapped std::size_t silently
// (the digit cap + overflow guard now reject it outright).
CHECK(!parseFingerprint("rsprov1" + std::string(200, '9') + ":x").has_value());
// A SIZE_MAX-magnitude length: the additive bounds check `start + len > size` could
// itself wrap and pass; the subtraction-first form rejects.
CHECK(!parseFingerprint("rsprov118446744073709551615:x").has_value());
// One past SIZE_MAX: the per-digit overflow guard fires during the accumulate.
CHECK(!parseFingerprint("rsprov118446744073709551616:x").has_value());
}
static void testHugeGuidCountRejectedBeforeReserve() {
// A GUID count astronomically larger than the wire could hold must return nullopt
// WITHOUT reaching trackGuids.reserve(count) — pre-fix this drove reserve(10^16)
// into std::length_error / bad_alloc thrown through the shell.
CHECK(!parseFingerprint(forgedFingerprint("9999999999999999")).has_value());
// A count merely past the wire-size sanity bound (each GUID field needs >= 2 wire
// bytes) is provably bogus and rejected before the field loop.
CHECK(!parseFingerprint(forgedFingerprint("1000")).has_value());
// A digit run past 20 fails the count parser's cap.
CHECK(!parseFingerprint(forgedFingerprint(std::string(25, '9'))).has_value());
// Sanity (non-vacuous forgery): the honest zero-count version of the same forged
// shape parses fine — the rejections above are the count's doing, not the shape's.
CHECK(parseFingerprint(forgedFingerprint("0")).has_value());
}
// --- recorded-recipe model round-trips through the Sample JSON ----------------
// The fingerprint rides in Provenance.fxChainSnapshot (one string), which M1's
// BankIndex JSON already round-trips. Prove a real recipe survives that path intact.
@@ -296,6 +350,8 @@ int main() {
testFxChainIdentityInjectionProof();
testCombineChainIdentities();
testMalformedFingerprint();
testHardenedCursorRejectsHostileLengths();
testHugeGuidCountRejectedBeforeReserve();
testRecipeThroughSampleJson();
testDetectParentPositive();
testDetectParentMultipleSameParent();
+67
View File
@@ -2474,6 +2474,68 @@ static void testPreserveTriggerTailGapFree() {
}
}
// --- Q-W0 T1-03: the Preserve prime is bounded by the PLAYABLE span. A Trigger zone whose
// play length is shorter than the OLA window must never carry source PAST the user's
// chosen stop into the ring — pre-fix, the prime pulled a full window bounded only by
// frameCount, and an up-shifted tap PLAYED the cut content (transposed) before the voice
// freed. The sample poisons everything past playEnd with amplitude 8: if any of it
// reaches the output, the peak bound fails. ---
static void testPreservePrimeStopsAtTriggerPlayEnd() {
const std::size_t frames = 8000;
const std::size_t w = 2048; // OLA window >> playable span
const std::size_t playLen = 500; // playEnd = round(8000 * 0.0625) = 500
SampleData s;
s.frames.resize(frames);
const double f0 = 1.0 / 50.0; // 10 cycles inside the playable span
for (std::size_t i = 0; i < frames; ++i) {
s.frames[i] = i < playLen
? static_cast<float>(std::sin(2.0 * kPi * f0 * static_cast<double>(i)))
: 8.0f; // POISON: cut content past the play end
}
s.rootNote = 60;
s.play.playMode = PlayMode::Trigger;
s.play.pitchEngine = PitchEngine::Preserve;
s.play.trigger.lengthFraction = 0.0625; // exactly 500 / 8000
Keymap km = Keymap::singleSampleChromatic(std::move(s));
VoiceEngine eng(1, km, /*preserveCap=*/0, static_cast<std::int64_t>(w));
eng.noteOn(72, 127); // +1 octave: the tap outruns the read head into
// the deepest primed history the ring holds
std::vector<AudioSample> out;
eng.render(out, playLen + 64); // through the voice's own end (readPos >= playEnd)
double peak = 0.0;
for (const AudioSample v : out) {
const double a = std::fabs(static_cast<double>(v));
if (a > peak) peak = a;
}
CHECK(peak < 1.5); // the 8.0 poison never sounds: nothing past playEnd entered the ring
CHECK(peak > 0.4); // ...and the real span genuinely played (the bound is not vacuous)
}
// --- Q-W0 T1-03 (companion): a whole sample SHORTER than the window (Gate, no loop) must not
// get zero padding declared as valid ring history — pre-fix, the prime zero-filled the
// window remainder with filled_ = window, so splices/tap travel landed in silence:
// hundreds-of-frames dead runs inside a sub-window one-shot (the pre-GA2 burst/gap
// artifact re-entering for short material). Post-fix the prime stops at the sample end
// and freezes the tail immediately, so the ring recycles ONLY real content. ---
static void testPreserveSubWindowSampleNoZeroPadInRing() {
const std::size_t frames = 1200; // sample < one window
const std::size_t w = 2048;
const double f0 = 1.0 / 96.0; // period 96: zero crossings dwell ~2 frames
SampleData s = tailSine(frames, f0, 60);
s.play.pitchEngine = PitchEngine::Preserve;
s.play.adsr = flatAdsr();
Keymap km = Keymap::singleSampleChromatic(std::move(s));
VoiceEngine eng(1, km, /*preserveCap=*/0, static_cast<std::int64_t>(w));
eng.noteOn(72, 127); // +1 octave up-shift (tap sweeps the whole ring)
std::vector<AudioSample> out;
eng.render(out, frames); // voice runs to its natural end (no loop)
// Pre-fix: the tap crossed the declared-valid zero pad repeatedly — quiet runs of 150+
// frames. Post-fix every relocation stays inside the real filled span; only sine zero
// crossings dip below the threshold.
CHECK(worstQuietRun(out, 0, frames, 0.05) < 30);
CHECK(blockPeak(out, 0, frames) > 0.5); // and it genuinely played at full level
}
int main() {
testChromaticSingleRoot();
testZonedRangesBoundaries();
@@ -2584,6 +2646,11 @@ int main() {
testPreserveTailReleaseContinuous();
testPreserveTriggerTailGapFree();
// Q-W0 T1-03 — the prime is bounded by the playable span (Trigger playEnd / sample end),
// with an immediate tail freeze on sub-window spans.
testPreservePrimeStopsAtTriggerPlayEnd();
testPreserveSubWindowSampleNoZeroPadInRing();
if (g_fail == 0) {
std::printf("all sampler_core tests passed\n");
return 0;