From 15d293b42f56680fb5a1a88f03963d79d1a81799 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Tue, 28 Jul 2026 18:44:52 -0400 Subject: [PATCH] =?UTF-8?q?fix(q-w0):=20six=20audit=20fix-nows=20=E2=80=94?= =?UTF-8?q?=20linked-lag=20stereo=20SOLA,=20playable-span=20prime=20bound,?= =?UTF-8?q?=20declick=20dead-state,=20provenance=20cursor=20hardening,=20r?= =?UTF-8?q?ate-derived=20gain=20ramp=20+=20fade=20ceiling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/provenance.cpp | 45 +++++++++++++++----- src/vst/pitch_shift.cpp | 43 ++++++++++++++++++- src/vst/pitch_shift.h | 56 +++++++++++++++++++++---- src/vst/reasampler_editor.cpp | 33 +++++++++++---- src/vst/reasampler_editor.h | 5 +++ src/vst/reasampler_processor.cpp | 36 ++++++++++------ src/vst/reasampler_processor.h | 9 +++- src/vst/sampler_core.cpp | 72 ++++++++++++++++++++++---------- src/vst/sampler_core.h | 7 ++-- tests/test_pitch_shift.cpp | 66 +++++++++++++++++++++++++++++ tests/test_provenance.cpp | 56 +++++++++++++++++++++++++ tests/test_sampler_core.cpp | 67 +++++++++++++++++++++++++++++ 12 files changed, 428 insertions(+), 67 deletions(-) diff --git a/src/provenance.cpp b/src/provenance.cpp index 6054924..5423af7 100644 --- a/src/provenance.cpp +++ b/src/provenance.cpp @@ -2,6 +2,7 @@ #include #include +#include // 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(c - '0'); + const std::size_t digit = static_cast(c - '0'); + // Overflow guard: if len would exceed SIZE_MAX after multiply+add, fail. + if (len > (std::numeric_limits::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(c - '0'); + const std::size_t digit = static_cast(c - '0'); + if (v > (std::numeric_limits::max() - digit) / 10u) + return fail(); + v = v * 10u + digit; } out = v; return true; @@ -196,6 +214,11 @@ std::optional 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; diff --git a/src/vst/pitch_shift.cpp b/src/vst/pitch_shift.cpp index 2fd34fc..59aa73d 100644 --- a/src/vst/pitch_shift.cpp +++ b/src/vst/pitch_shift.cpp @@ -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) { @@ -311,11 +315,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(ev.jump) + static_cast(ev.lag) + ev.frac; + const double len = static_cast(ringLen_); + while (p < 0.0) p += len; + while (p >= len) p -= len; + posA_ = p; + fadeLen_ = std::max(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 +370,12 @@ 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 { // 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- diff --git a/src/vst/pitch_shift.h b/src/vst/pitch_shift.h index 5fdbf1d..aed90d6 100644 --- a/src/vst/pitch_shift.h +++ b/src/vst/pitch_shift.h @@ -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 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 diff --git a/src/vst/reasampler_editor.cpp b/src/vst/reasampler_editor.cpp index a46c222..744f4c1 100644 --- a/src/vst/reasampler_editor.cpp +++ b/src/vst/reasampler_editor.cpp @@ -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,11 @@ 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. + const double fadeMax = fadeMaxFrames(); const auto secToNorm = [](double s) { return clamp01(s / kEnvTimeMaxSeconds); }; - const auto framesToNorm = [](std::int64_t f) { - return clamp01(static_cast(f) / kFadeMaxFrames); + const auto framesToNorm = [fadeMax](std::int64_t f) { + return clamp01(static_cast(f) / fadeMax); }; switch (static_cast(id)) { case ParamControl::kPlayMode: return play.playMode == PlayMode::Trigger ? 1.0 : 0.0; @@ -429,9 +433,10 @@ 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(clamp01(v) * kFadeMaxFrames + 0.5); + const auto normToFrames = [fadeMax](double v) { + return static_cast(clamp01(v) * fadeMax + 0.5); }; switch (static_cast(id)) { case ParamControl::kPlayMode: @@ -467,6 +472,18 @@ 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). The + // 44.1 kHz fallback covers the pre-setupProcessing window (rate still 0) and reproduces + // the legacy 88200-frame ceiling there. Storage stays SOURCE FRAMES — this resolves the + // UI ceiling only. + const double rate = liveSampleRate(); + return kFadeMaxSeconds * (rate > 0.0 ? rate : 44100.0); +} + double ReaSamplerEditor::previewVelocity01() const { if (!processor_) return static_cast(kPreviewVelocityDefault) / 127.0; return static_cast(processor_->previewVelocity()) / 127.0; diff --git a/src/vst/reasampler_editor.h b/src/vst/reasampler_editor.h index 238a2f8..91e0082 100644 --- a/src/vst/reasampler_editor.h +++ b/src/vst/reasampler_editor.h @@ -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 diff --git a/src/vst/reasampler_processor.cpp b/src/vst/reasampler_processor.cpp index 027527c..eb8f2d4 100644 --- a/src/vst/reasampler_processor.cpp +++ b/src/vst/reasampler_processor.cpp @@ -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(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_; } diff --git a/src/vst/reasampler_processor.h b/src/vst/reasampler_processor.h index 703884c..73f0e85 100644 --- a/src/vst/reasampler_processor.h +++ b/src/vst/reasampler_processor.h @@ -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 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() diff --git a/src/vst/sampler_core.cpp b/src/vst/sampler_core.cpp index 2b8e24f..c0920cd 100644 --- a/src/vst/sampler_core.cpp +++ b/src/vst/sampler_core.cpp @@ -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(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& 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(i)] = (q < frameCount) ? pcmCh[static_cast(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(feedPos_)] : 0.0f; shiftR_.setShiftRatio(shift); - outRlocal = static_cast(shiftR_.process(feedR)) * gain; + outRlocal = + static_cast(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; } } diff --git a/src/vst/sampler_core.h b/src/vst/sampler_core.h index 90dc92a..c2c68bc 100644 --- a/src/vst/sampler_core.h +++ b/src/vst/sampler_core.h @@ -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ₙ*(1−w) + ref*w where w = declickL_/R_ starts at 1.0 and decays by + // outₙ = outₙ*(1−w) + 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; diff --git a/tests/test_pitch_shift.cpp b/tests/test_pitch_shift.cpp index b913993..255a5aa 100644 --- a/tests/test_pitch_shift.cpp +++ b/tests/test_pitch_shift.cpp @@ -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,68 @@ 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). --- +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 srcL(n + static_cast(w)); + std::vector srcR(n + static_cast(w)); + for (std::size_t i = 0; i < srcL.size(); ++i) { + srcL[i] = static_cast(std::sin(2.0 * kPi * fL * static_cast(i))); + srcR[i] = static_cast(std::sin(2.0 * kPi * fR * static_cast(i))); + } + PitchShifter master, follower, independent; + master.configure(w); + follower.configure(w); + independent.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) + master.setShiftRatio(2.0); + follower.setShiftRatio(2.0); + independent.setShiftRatio(2.0); + + int spliceCount = 0; + bool followerDiverged = false; + bool independentDiverged = false; + for (std::size_t i = 0; i < n; ++i) { + const std::size_t si = i + static_cast(w); + (void)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 — a follower must never splice on its own). + 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; + } + } + 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 +} + int main() { testDurationInvariance(); testUnityRoughlyReproduces(); @@ -499,6 +564,7 @@ int main() { testRepitchSpectralPurityAndOnset(); testUnityBitExactAndLatency(); testFreezeTailContinuousTone(); + testStereoLinkedLagSharedSchedule(); if (g_fail == 0) { std::printf("all pitch_shift tests passed\n"); diff --git a/tests/test_provenance.cpp b/tests/test_provenance.cpp index 3671f93..a2b19f3 100644 --- a/tests/test_provenance.cpp +++ b/tests/test_provenance.cpp @@ -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(); diff --git a/tests/test_sampler_core.cpp b/tests/test_sampler_core.cpp index e494d30..279953c 100644 --- a/tests/test_sampler_core.cpp +++ b/tests/test_sampler_core.cpp @@ -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(std::sin(2.0 * kPi * f0 * static_cast(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(w)); + eng.noteOn(72, 127); // +1 octave: the tap outruns the read head into + // the deepest primed history the ring holds + std::vector 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(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(w)); + eng.noteOn(72, 127); // +1 octave up-shift (tap sweeps the whole ring) + std::vector 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;