fix(q-w0): six audit fix-nows — linked-lag stereo SOLA, playable-span prime bound, declick dead-state, provenance cursor hardening, rate-derived gain ramp + fade ceiling
This commit is contained in:
+34
-11
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
#include <cstdlib>
|
#include <cstdlib>
|
||||||
|
#include <limits>
|
||||||
|
|
||||||
// provenance implementation — pure, self-contained (no third-party lib, mirror of
|
// provenance implementation — pure, self-contained (no third-party lib, mirror of
|
||||||
// bank_model's hand-rolled encoding discipline).
|
// bank_model's hand-rolled encoding discipline).
|
||||||
@@ -60,22 +61,33 @@ public:
|
|||||||
bool ok() const { return ok_; }
|
bool ok() const { return ok_; }
|
||||||
bool atEnd() const { return pos_ >= s_.size(); }
|
bool atEnd() const { return pos_ >= s_.size(); }
|
||||||
|
|
||||||
// Reads one length-prefixed field into `out`. Fails on a missing ':',
|
// Reads one length-prefixed field into `out`. Fails on a missing ':', an empty or
|
||||||
// non-numeric length, or a length that runs past the end.
|
// 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) {
|
bool field(std::string& out) {
|
||||||
if (!ok_) return false;
|
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();
|
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
|
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) {
|
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();
|
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;
|
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);
|
out.assign(s_, start, len);
|
||||||
pos_ = start + len;
|
pos_ = start + len;
|
||||||
return true;
|
return true;
|
||||||
@@ -87,14 +99,20 @@ public:
|
|||||||
return toInt(f, out);
|
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) {
|
bool fieldSizeT(std::size_t& out) {
|
||||||
std::string f;
|
std::string f;
|
||||||
if (!field(f)) return false;
|
if (!field(f)) return false;
|
||||||
if (f.empty()) return fail();
|
if (f.empty() || f.size() > 20u) return fail();
|
||||||
std::size_t v = 0;
|
std::size_t v = 0;
|
||||||
for (char c : f) {
|
for (const char c : f) {
|
||||||
if (c < '0' || c > '9') return fail();
|
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;
|
out = v;
|
||||||
return true;
|
return true;
|
||||||
@@ -196,6 +214,11 @@ std::optional<CaptureRecipe> parseFingerprint(const std::string& fingerprint) {
|
|||||||
|
|
||||||
std::size_t guidCount = 0;
|
std::size_t guidCount = 0;
|
||||||
if (!c.fieldSizeT(guidCount)) return std::nullopt;
|
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);
|
r.trackGuids.reserve(guidCount);
|
||||||
for (std::size_t i = 0; i < guidCount; ++i) {
|
for (std::size_t i = 0; i < guidCount; ++i) {
|
||||||
std::string g;
|
std::string g;
|
||||||
|
|||||||
+42
-1
@@ -48,6 +48,7 @@ void PitchShifter::configure(std::int64_t windowFrames) {
|
|||||||
filled_ = 0;
|
filled_ = 0;
|
||||||
ratio_ = 1.0;
|
ratio_ = 1.0;
|
||||||
tailFrozen_ = false;
|
tailFrozen_ = false;
|
||||||
|
lastSplice_ = SpliceEvent{};
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// 2x-window ring: one window of splice-jump span plus search + fade headroom on each side.
|
// 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;
|
filled_ = 0;
|
||||||
ratio_ = 1.0;
|
ratio_ = 1.0;
|
||||||
tailFrozen_ = false;
|
tailFrozen_ = false;
|
||||||
|
lastSplice_ = SpliceEvent{};
|
||||||
}
|
}
|
||||||
|
|
||||||
void PitchShifter::freezeTail() {
|
void PitchShifter::freezeTail() {
|
||||||
@@ -146,6 +148,7 @@ void PitchShifter::prime(const AudioSample* src, std::int64_t count) {
|
|||||||
fadeLen_ = 0;
|
fadeLen_ = 0;
|
||||||
filled_ = count;
|
filled_ = count;
|
||||||
tailFrozen_ = false; // a fresh note-on always starts with a live writer
|
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.
|
// ratio_ deliberately untouched: the voice sets it per frame around the prime.
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,6 +166,7 @@ void PitchShifter::warm() {
|
|||||||
fadeLen_ = 0;
|
fadeLen_ = 0;
|
||||||
filled_ = window_;
|
filled_ = window_;
|
||||||
tailFrozen_ = false;
|
tailFrozen_ = false;
|
||||||
|
lastSplice_ = SpliceEvent{};
|
||||||
}
|
}
|
||||||
|
|
||||||
void PitchShifter::setShiftRatio(double ratio) {
|
void PitchShifter::setShiftRatio(double ratio) {
|
||||||
@@ -311,11 +315,42 @@ void PitchShifter::splice(std::int64_t nominalJump, double delay) {
|
|||||||
}
|
}
|
||||||
fading_ = true;
|
fading_ = true;
|
||||||
fadePos_ = 0;
|
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)
|
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
|
// 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).
|
// 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
|
// 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));
|
const double gNew = 0.5 * (1.0 - std::cos(kPi * t));
|
||||||
out = gNew * out + (1.0 - gNew) * readTap(posB_);
|
out = gNew * out + (1.0 - gNew) * readTap(posB_);
|
||||||
if (++fadePos_ >= fadeLen_) fading_ = false;
|
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 {
|
} else {
|
||||||
// 3. Splice scheduling: relocate when the active tap's delay leaves the safe band.
|
// 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-
|
// Up-shifts (ratio > 1) drain the delay toward 0 -> jump one window OLDER; down-
|
||||||
|
|||||||
+49
-7
@@ -70,9 +70,25 @@
|
|||||||
|
|
||||||
namespace reasampler {
|
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 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
|
// a stereo voice owns two, LINKED: channel 0 is the master, channel 1 follows its splice
|
||||||
// the S7 "one read head, per-channel value" idiom of the core.
|
// 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
|
// 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
|
// 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
|
// 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
|
// 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
|
// 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
|
// history to land in — the GA2 onset-gap fix. `count` is clamped to [0, window()].
|
||||||
// the full window (pad the tail with silence yourself if the source is shorter — trailing
|
// When the PLAYABLE source is shorter than one window, prime only the real span and call
|
||||||
// silence IS the true stream there). RT-safe: bounded copy into the pre-sized ring, no
|
// freezeTail() immediately after (Q-W0 T1-03): the GA3 machinery then recycles the real
|
||||||
// allocation. No-op when unconfigured. The current shift ratio is left untouched.
|
// 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);
|
void prime(const AudioSample* src, std::int64_t count);
|
||||||
|
|
||||||
// prime()-with-silence: zero the ring, park the tap one window behind the writer, and
|
// 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.
|
// active tap leaves its safe delay band, a correlation-aligned splice is scheduled.
|
||||||
AudioSample process(AudioSample in);
|
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
|
// 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
|
// 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
|
// 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
|
double readTap(double pos) const; // fractional ring read, linear interp
|
||||||
// Relocate the active tap by ~`nominalJump` frames of added delay (clamped to the filled
|
// 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
|
// 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);
|
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::vector<AudioSample> ring_; // delay line, length `ringLen_` == 2 * window_
|
||||||
std::int64_t window_ = 0; // nominal splice jump in frames; <= 1 = pass-through
|
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
|
// its up-jump to this so no splice lands in unwritten
|
||||||
// silence — the GA2 onset-gap fix.
|
// silence — the GA2 onset-gap fix.
|
||||||
double ratio_ = 1.0; // current shift ratio (>0)
|
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
|
bool tailFrozen_ = false; // GA3 wind-down: writer frozen (source exhausted); the tap
|
||||||
// recycles the ring's frozen real tail, splices still
|
// recycles the ring's frozen real tail, splices still
|
||||||
// aligned. With the writer parked, a tap drains toward it
|
// aligned. With the writer parked, a tap drains toward it
|
||||||
|
|||||||
@@ -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
|
// 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
|
// [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)
|
// 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
|
// STORE source frames (PLAN.md §S15 — never a wall-clock second; the storage domain is
|
||||||
// wall-clock second). Build-time residual — one place to retune; not persisted.
|
// 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 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 kPitchDepthMaxSemis = 24.0; // AD pitch depth throw: +/-24 st, centered
|
||||||
constexpr double kKeyTrackMax = 2.0; // S-VIEW-6 key-track slider ceiling (0..200%)
|
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 {
|
double ReaSamplerEditor::controlValue(int id, const ZonePlaySeconds& play) const {
|
||||||
// Wall-clock seconds -> normalized over the seconds ceiling; source frames -> normalized over
|
// 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 secToNorm = [](double s) { return clamp01(s / kEnvTimeMaxSeconds); };
|
||||||
const auto framesToNorm = [](std::int64_t f) {
|
const auto framesToNorm = [fadeMax](std::int64_t f) {
|
||||||
return clamp01(static_cast<double>(f) / kFadeMaxFrames);
|
return clamp01(static_cast<double>(f) / fadeMax);
|
||||||
};
|
};
|
||||||
switch (static_cast<ParamControl>(id)) {
|
switch (static_cast<ParamControl>(id)) {
|
||||||
case ParamControl::kPlayMode: return play.playMode == PlayMode::Trigger ? 1.0 : 0.0;
|
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,
|
void ReaSamplerEditor::applyControl(int id, ZonePlaySeconds& play, double value,
|
||||||
int segment) const {
|
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 normToSec = [](double v) { return clamp01(v) * kEnvTimeMaxSeconds; };
|
||||||
const auto normToFrames = [](double v) {
|
const auto normToFrames = [fadeMax](double v) {
|
||||||
return static_cast<std::int64_t>(clamp01(v) * kFadeMaxFrames + 0.5);
|
return static_cast<std::int64_t>(clamp01(v) * fadeMax + 0.5);
|
||||||
};
|
};
|
||||||
switch (static_cast<ParamControl>(id)) {
|
switch (static_cast<ParamControl>(id)) {
|
||||||
case ParamControl::kPlayMode:
|
case ParamControl::kPlayMode:
|
||||||
@@ -467,6 +472,18 @@ double ReaSamplerEditor::liveSampleRate() const {
|
|||||||
return processor_ ? processor_->sampleRate() : 0.0;
|
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 {
|
double ReaSamplerEditor::previewVelocity01() const {
|
||||||
if (!processor_) return static_cast<double>(kPreviewVelocityDefault) / 127.0;
|
if (!processor_) return static_cast<double>(kPreviewVelocityDefault) / 127.0;
|
||||||
return static_cast<double>(processor_->previewVelocity()) / 127.0;
|
return static_cast<double>(processor_->previewVelocity()) / 127.0;
|
||||||
|
|||||||
@@ -318,6 +318,11 @@ private:
|
|||||||
// into the control's stored domain) or a toggle's `segment` (0/1). Mutates `play` in place.
|
// 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;
|
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) ----------
|
// --- S-VIEW-3 envelope overlay seam (frames <-> fraction converter) ----------
|
||||||
//
|
//
|
||||||
// envelope_overlay's AmpEnvelope is a DERIVED VIEW, not a TriggerParams copy: it stores the
|
// envelope_overlay's AmpEnvelope is a DERIVED VIEW, not a TriggerParams copy: it stores the
|
||||||
|
|||||||
@@ -43,12 +43,14 @@ namespace {
|
|||||||
// FIXED so raising the voice count never multiplies shifter CPU past the profiled budget.
|
// FIXED so raising the voice count never multiplies shifter CPU past the profiled budget.
|
||||||
constexpr std::size_t kPreserveVoiceCap = 8;
|
constexpr std::size_t kPreserveVoiceCap = 8;
|
||||||
|
|
||||||
// FB1 post-mixer gain ramp rate (per sample). gainCurrent_ converges to masterGain_ at this
|
// FB1 post-mixer gain ramp TIME (wall-clock). gainCurrent_ converges to masterGain_ by a
|
||||||
// linear step; it ramps from 0 to unity (or vice versa) in ~20 ms at 48 kHz. The early-out
|
// linear per-sample step derived from this at setupProcessing (gainRampStep_ =
|
||||||
// (|current - target| < threshold) snaps to the target and avoids the ramp loop on idle blocks.
|
// 1 / (kGainRampSeconds * sampleRate_)) — the kPreserveWindowMs pattern, per the standing
|
||||||
// kGainRampSnap is the threshold below which we snap to the target (avoids long sub-LSB creep).
|
// no-hardcoded-rate ruling (Q-W0 T3-01; the prior constant baked 20 ms x 48 kHz in as
|
||||||
constexpr float kGainRampRate = 1.0f / 960.0f; // 960 samples @ 48 kHz ≈ 20 ms
|
// 1/960, silently shortening the ramp at higher host rates). A full 0-to-unity ramp is
|
||||||
constexpr float kGainRampSnap = kGainRampRate * 0.5f;
|
// ~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
|
// 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
|
// 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) {
|
tresult PLUGIN_API ReaSamplerProcessor::setupProcessing(ProcessSetup& setup) {
|
||||||
sampleRate_ = setup.sampleRate;
|
sampleRate_ = setup.sampleRate;
|
||||||
maxBlockSize_ = setup.maxSamplesPerBlock;
|
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);
|
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.
|
// 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 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_;
|
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.
|
// Ramp toward target: step per sample, then apply the per-sample gain.
|
||||||
for (int32 i = 0; i < frames; ++i) {
|
for (int32 i = 0; i < frames; ++i) {
|
||||||
const float d = gTarget - gainCurrent_;
|
const float d = gTarget - gainCurrent_;
|
||||||
if (d > kGainRampRate) gainCurrent_ += kGainRampRate;
|
if (d > gStep) gainCurrent_ += gStep;
|
||||||
else if (d < -kGainRampRate) gainCurrent_ -= kGainRampRate;
|
else if (d < -gStep) gainCurrent_ -= gStep;
|
||||||
else gainCurrent_ = gTarget;
|
else gainCurrent_ = gTarget;
|
||||||
ch0[i] *= gainCurrent_;
|
ch0[i] *= gainCurrent_;
|
||||||
ch1[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.
|
// post-sum, pre-peak/replicate, per-sample gainCurrent_ ramp toward target, RT-safe.
|
||||||
{
|
{
|
||||||
const float gTarget = masterGain_.load(std::memory_order_relaxed);
|
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_;
|
const float diff = gTarget - gainCurrent_;
|
||||||
if (diff < -kGainRampSnap || diff > kGainRampSnap) {
|
if (diff < -gSnap || diff > gSnap) {
|
||||||
for (int32 i = 0; i < frames; ++i) {
|
for (int32 i = 0; i < frames; ++i) {
|
||||||
const float d = gTarget - gainCurrent_;
|
const float d = gTarget - gainCurrent_;
|
||||||
if (d > kGainRampRate) gainCurrent_ += kGainRampRate;
|
if (d > gStep) gainCurrent_ += gStep;
|
||||||
else if (d < -kGainRampRate) gainCurrent_ -= kGainRampRate;
|
else if (d < -gStep) gainCurrent_ -= gStep;
|
||||||
else gainCurrent_ = gTarget;
|
else gainCurrent_ = gTarget;
|
||||||
ch0[i] *= gainCurrent_;
|
ch0[i] *= gainCurrent_;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -454,13 +454,18 @@ private:
|
|||||||
|
|
||||||
// FB1 post-mixer master gain (LINEAR; persisted in component state v8). A lock-free
|
// 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
|
// 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.
|
// so sudden knob moves produce no zipper noise and the true-zero bottom causes no click.
|
||||||
std::atomic<float> masterGain_{1.0f};
|
std::atomic<float> masterGain_{1.0f};
|
||||||
// The audio-thread running gain value: tracks masterGain_ across blocks, stepping at
|
// 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.
|
// Written and read exclusively on the audio thread — no atomics needed.
|
||||||
float gainCurrent_ = 1.0f;
|
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) ---------
|
// --- 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()
|
// The editor's preview-trigger button posts a note-on/off request from the UI thread; process()
|
||||||
|
|||||||
+49
-23
@@ -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
|
// 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).
|
// includes the running declick's contribution via lastOut (it tracks post-declick output).
|
||||||
declickActive_ = false;
|
declickActive_ = false;
|
||||||
declickL_ = 0.0;
|
declickWeight_ = 0.0;
|
||||||
declickR_ = 0.0;
|
|
||||||
|
|
||||||
active_ = true;
|
active_ = true;
|
||||||
releasing_ = false;
|
releasing_ = false;
|
||||||
@@ -378,25 +377,49 @@ void Voice::start(int note, int velocity, const SampleData& sample, int rootNote
|
|||||||
const SampleLoop& loop = sample.loop;
|
const SampleLoop& loop = sample.loop;
|
||||||
const std::int64_t loopLen = loopWrap ? (loop.end - loop.start) : 0;
|
const std::int64_t loopLen = loopWrap ? (loop.end - loop.start) : 0;
|
||||||
const bool stereoSample = sample.channelCount() == 2 && shiftR_.configured();
|
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,
|
// 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.
|
// not on channel PCM values) — compute `p` once for channel 0, reuse for channel 1.
|
||||||
std::int64_t p = start;
|
std::int64_t p = start;
|
||||||
for (int ch = 0; ch < (stereoSample ? 2 : 1); ++ch) {
|
for (int ch = 0; ch < (stereoSample ? 2 : 1); ++ch) {
|
||||||
const std::vector<AudioSample>& pcmCh = ch == 0 ? sample.frames : sample.framesR;
|
const std::vector<AudioSample>& pcmCh = ch == 0 ? sample.frames : sample.framesR;
|
||||||
std::int64_t q = start;
|
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) {
|
if (loopWrap) {
|
||||||
while (q >= loop.end) q -= loopLen;
|
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)] =
|
primeBuf_[static_cast<std::size_t>(i)] =
|
||||||
(q < frameCount) ? pcmCh[static_cast<std::size_t>(q)] : 0.0f;
|
(q < frameCount) ? pcmCh[static_cast<std::size_t>(q)] : 0.0f;
|
||||||
++q;
|
++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
|
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;
|
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.
|
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.
|
// gone: the blend formula keeps every output within max(|ref|,|outₙ|) by construction.
|
||||||
(void)newOutL; (void)newOutR; // consumed only for the floor guard below
|
(void)newOutL; (void)newOutR; // consumed only for the floor guard below
|
||||||
declickPending_ = false;
|
declickPending_ = false;
|
||||||
declickL_ = 1.0;
|
declickWeight_ = 1.0; // ONE weight for both channels (T1-09: the per-R copy was dead state)
|
||||||
declickR_ = 1.0;
|
|
||||||
// The reference is already clamped to ±1.0 at start() (lines in start(): the ±1 clamp
|
// 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.
|
// 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.
|
// 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_) {
|
if (declickActive_) {
|
||||||
// Bounded blend at silence: outCurrent == 0, so the blend is w*(ref − 0) == w*ref.
|
// 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.
|
// The weight decays by kDeclickDecay each frame, floor-checked on the weight itself.
|
||||||
const double l = declickL_ * declickRefL_;
|
const double l = declickWeight_ * declickRefL_;
|
||||||
const double r = declickL_ * declickRefR_; // same weight for both channels
|
const double r = declickWeight_ * declickRefR_; // same weight for both channels
|
||||||
declickL_ *= kDeclickDecay;
|
declickWeight_ *= kDeclickDecay;
|
||||||
declickR_ *= kDeclickDecay;
|
if (declickWeight_ < kDeclickFloor && declickWeight_ > -kDeclickFloor) {
|
||||||
if (declickL_ < kDeclickFloor && declickL_ > -kDeclickFloor) {
|
|
||||||
declickActive_ = false;
|
declickActive_ = false;
|
||||||
active_ = false;
|
active_ = false;
|
||||||
}
|
}
|
||||||
@@ -578,15 +599,21 @@ AudioSample Voice::advanceFrame(bool stereo, AudioSample& outR) {
|
|||||||
outL = shiftedL * gain;
|
outL = shiftedL * gain;
|
||||||
if (stereo) {
|
if (stereo) {
|
||||||
if (haveR && shiftR_.configured()) {
|
if (haveR && shiftR_.configured()) {
|
||||||
// Genuine stereo: an independent shifter transposes channel 1. Each shifter is
|
// Genuine stereo (Q-W0 T1-01, linked lag): channel 1's shifter FOLLOWS channel
|
||||||
// process()'d EXACTLY ONCE per output frame (never twice — that would advance its
|
// 0's splice decisions via processLinked — one correlation search, one lag, one
|
||||||
// heads twice and corrupt the OLA state). Gated on haveR so a MONO sample never
|
// splice schedule for both channels (standard stereo SOLA). An independent
|
||||||
// touches shiftR_ — start() only primes it for genuinely stereo samples, and a
|
// per-channel search re-drew an inter-channel offset of up to +/-maxLag at
|
||||||
// stale un-primed ring must not leak a previous note into this one.
|
// 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();
|
if (exhausted) shiftR_.freezeTail();
|
||||||
const AudioSample feedR = feedOk ? pcmR[static_cast<std::size_t>(feedPos_)] : 0.0f;
|
const AudioSample feedR = feedOk ? pcmR[static_cast<std::size_t>(feedPos_)] : 0.0f;
|
||||||
shiftR_.setShiftRatio(shift);
|
shiftR_.setShiftRatio(shift);
|
||||||
outRlocal = static_cast<double>(shiftR_.process(feedR)) * gain;
|
outRlocal =
|
||||||
|
static_cast<double>(shiftR_.processLinked(feedR, shiftL_.lastSplice())) *
|
||||||
|
gain;
|
||||||
} else {
|
} else {
|
||||||
// Mono sample in stereo mode (dual-mono): shiftL_ already produced the shifted
|
// 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
|
// 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.
|
// Inactive (the common case) costs one branch; the blend itself costs one extra subtract.
|
||||||
if (declickPending_) seedDeclick(outL, stereo ? outRlocal : outL);
|
if (declickPending_) seedDeclick(outL, stereo ? outRlocal : outL);
|
||||||
if (declickActive_) {
|
if (declickActive_) {
|
||||||
const double addL = declickL_ * (declickRefL_ - outL);
|
const double addL = declickWeight_ * (declickRefL_ - outL);
|
||||||
const double addR = declickL_ * (declickRefR_ - (stereo ? outRlocal : outL));
|
const double addR = declickWeight_ * (declickRefR_ - (stereo ? outRlocal : outL));
|
||||||
outL += addL;
|
outL += addL;
|
||||||
if (stereo) outRlocal += addR;
|
if (stereo) outRlocal += addR;
|
||||||
declickL_ *= kDeclickDecay;
|
declickWeight_ *= kDeclickDecay; // one shared weight — both channels decay together
|
||||||
declickR_ *= kDeclickDecay; // kept in sync (mirrors L — both channels share one weight)
|
if (declickWeight_ < kDeclickFloor && declickWeight_ > -kDeclickFloor) {
|
||||||
if (declickL_ < kDeclickFloor && declickL_ > -kDeclickFloor) {
|
|
||||||
declickActive_ = false;
|
declickActive_ = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -582,7 +582,9 @@ private:
|
|||||||
// recent rendered output (post-gain, incl. any running declick). A takeover/steal start()
|
// 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_;
|
// 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:
|
// 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
|
// 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
|
// 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.
|
// max(|ref|, |outₙ|) — mid-ramp overshoot is impossible regardless of outₙ rising.
|
||||||
@@ -595,8 +597,7 @@ private:
|
|||||||
bool declickActive_ = false;
|
bool declickActive_ = false;
|
||||||
double declickRefL_ = 0.0; // clamped pre-cut reference (bounded blend target)
|
double declickRefL_ = 0.0; // clamped pre-cut reference (bounded blend target)
|
||||||
double declickRefR_ = 0.0;
|
double declickRefR_ = 0.0;
|
||||||
double declickL_ = 0.0; // blend weight w; 1.0 on seed, decays by kDeclickDecay/frame
|
double declickWeight_ = 0.0; // blend weight w; 1.0 on seed, decays by kDeclickDecay/frame
|
||||||
double declickR_ = 0.0;
|
|
||||||
double lastOutL_ = 0.0;
|
double lastOutL_ = 0.0;
|
||||||
double lastOutR_ = 0.0;
|
double lastOutR_ = 0.0;
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,9 @@
|
|||||||
// 6. unity + latency contract — asserted bit-exactly: a warm()ed shifter at ratio 1.0 IS a
|
// 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
|
// clean window delay; a prime()d one has ZERO added latency (out[i] == src[i] to the
|
||||||
// bit) — the GA2 immediate-onset claim.
|
// 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"
|
#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<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;
|
||||||
|
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<std::size_t>(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() {
|
int main() {
|
||||||
testDurationInvariance();
|
testDurationInvariance();
|
||||||
testUnityRoughlyReproduces();
|
testUnityRoughlyReproduces();
|
||||||
@@ -499,6 +564,7 @@ int main() {
|
|||||||
testRepitchSpectralPurityAndOnset();
|
testRepitchSpectralPurityAndOnset();
|
||||||
testUnityBitExactAndLatency();
|
testUnityBitExactAndLatency();
|
||||||
testFreezeTailContinuousTone();
|
testFreezeTailContinuousTone();
|
||||||
|
testStereoLinkedLagSharedSchedule();
|
||||||
|
|
||||||
if (g_fail == 0) {
|
if (g_fail == 0) {
|
||||||
std::printf("all pitch_shift tests passed\n");
|
std::printf("all pitch_shift tests passed\n");
|
||||||
|
|||||||
@@ -7,6 +7,8 @@
|
|||||||
// track GUIDs, FX-chain identity) -> a MISMATCH (different string / recipe).
|
// track GUIDs, FX-chain identity) -> a MISMATCH (different string / recipe).
|
||||||
// * fxChainIdentity fold: order-sensitive, field-injection-proof, empty-stable.
|
// * fxChainIdentity fold: order-sensitive, field-injection-proof, empty-stable.
|
||||||
// * parse of malformed / wrong-version / truncated input -> nullopt (graceful).
|
// * 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
|
// * parent-detection decision: positive, negative, ambiguous, empty, and the
|
||||||
// edge where a source file is not in the bank (missing-from-bank).
|
// 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());
|
"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 ----------------
|
// --- recorded-recipe model round-trips through the Sample JSON ----------------
|
||||||
// The fingerprint rides in Provenance.fxChainSnapshot (one string), which M1's
|
// The fingerprint rides in Provenance.fxChainSnapshot (one string), which M1's
|
||||||
// BankIndex JSON already round-trips. Prove a real recipe survives that path intact.
|
// BankIndex JSON already round-trips. Prove a real recipe survives that path intact.
|
||||||
@@ -296,6 +350,8 @@ int main() {
|
|||||||
testFxChainIdentityInjectionProof();
|
testFxChainIdentityInjectionProof();
|
||||||
testCombineChainIdentities();
|
testCombineChainIdentities();
|
||||||
testMalformedFingerprint();
|
testMalformedFingerprint();
|
||||||
|
testHardenedCursorRejectsHostileLengths();
|
||||||
|
testHugeGuidCountRejectedBeforeReserve();
|
||||||
testRecipeThroughSampleJson();
|
testRecipeThroughSampleJson();
|
||||||
testDetectParentPositive();
|
testDetectParentPositive();
|
||||||
testDetectParentMultipleSameParent();
|
testDetectParentMultipleSameParent();
|
||||||
|
|||||||
@@ -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() {
|
int main() {
|
||||||
testChromaticSingleRoot();
|
testChromaticSingleRoot();
|
||||||
testZonedRangesBoundaries();
|
testZonedRangesBoundaries();
|
||||||
@@ -2584,6 +2646,11 @@ int main() {
|
|||||||
testPreserveTailReleaseContinuous();
|
testPreserveTailReleaseContinuous();
|
||||||
testPreserveTriggerTailGapFree();
|
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) {
|
if (g_fail == 0) {
|
||||||
std::printf("all sampler_core tests passed\n");
|
std::printf("all sampler_core tests passed\n");
|
||||||
return 0;
|
return 0;
|
||||||
|
|||||||
Reference in New Issue
Block a user