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:
@@ -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<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() {
|
||||
testDurationInvariance();
|
||||
testUnityRoughlyReproduces();
|
||||
@@ -499,6 +564,7 @@ int main() {
|
||||
testRepitchSpectralPurityAndOnset();
|
||||
testUnityBitExactAndLatency();
|
||||
testFreezeTailContinuousTone();
|
||||
testStereoLinkedLagSharedSchedule();
|
||||
|
||||
if (g_fail == 0) {
|
||||
std::printf("all pitch_shift tests passed\n");
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -2474,6 +2474,68 @@ static void testPreserveTriggerTailGapFree() {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Q-W0 T1-03: the Preserve prime is bounded by the PLAYABLE span. A Trigger zone whose
|
||||
// play length is shorter than the OLA window must never carry source PAST the user's
|
||||
// chosen stop into the ring — pre-fix, the prime pulled a full window bounded only by
|
||||
// frameCount, and an up-shifted tap PLAYED the cut content (transposed) before the voice
|
||||
// freed. The sample poisons everything past playEnd with amplitude 8: if any of it
|
||||
// reaches the output, the peak bound fails. ---
|
||||
static void testPreservePrimeStopsAtTriggerPlayEnd() {
|
||||
const std::size_t frames = 8000;
|
||||
const std::size_t w = 2048; // OLA window >> playable span
|
||||
const std::size_t playLen = 500; // playEnd = round(8000 * 0.0625) = 500
|
||||
SampleData s;
|
||||
s.frames.resize(frames);
|
||||
const double f0 = 1.0 / 50.0; // 10 cycles inside the playable span
|
||||
for (std::size_t i = 0; i < frames; ++i) {
|
||||
s.frames[i] = i < playLen
|
||||
? static_cast<float>(std::sin(2.0 * kPi * f0 * static_cast<double>(i)))
|
||||
: 8.0f; // POISON: cut content past the play end
|
||||
}
|
||||
s.rootNote = 60;
|
||||
s.play.playMode = PlayMode::Trigger;
|
||||
s.play.pitchEngine = PitchEngine::Preserve;
|
||||
s.play.trigger.lengthFraction = 0.0625; // exactly 500 / 8000
|
||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||
VoiceEngine eng(1, km, /*preserveCap=*/0, static_cast<std::int64_t>(w));
|
||||
eng.noteOn(72, 127); // +1 octave: the tap outruns the read head into
|
||||
// the deepest primed history the ring holds
|
||||
std::vector<AudioSample> out;
|
||||
eng.render(out, playLen + 64); // through the voice's own end (readPos >= playEnd)
|
||||
double peak = 0.0;
|
||||
for (const AudioSample v : out) {
|
||||
const double a = std::fabs(static_cast<double>(v));
|
||||
if (a > peak) peak = a;
|
||||
}
|
||||
CHECK(peak < 1.5); // the 8.0 poison never sounds: nothing past playEnd entered the ring
|
||||
CHECK(peak > 0.4); // ...and the real span genuinely played (the bound is not vacuous)
|
||||
}
|
||||
|
||||
// --- Q-W0 T1-03 (companion): a whole sample SHORTER than the window (Gate, no loop) must not
|
||||
// get zero padding declared as valid ring history — pre-fix, the prime zero-filled the
|
||||
// window remainder with filled_ = window, so splices/tap travel landed in silence:
|
||||
// hundreds-of-frames dead runs inside a sub-window one-shot (the pre-GA2 burst/gap
|
||||
// artifact re-entering for short material). Post-fix the prime stops at the sample end
|
||||
// and freezes the tail immediately, so the ring recycles ONLY real content. ---
|
||||
static void testPreserveSubWindowSampleNoZeroPadInRing() {
|
||||
const std::size_t frames = 1200; // sample < one window
|
||||
const std::size_t w = 2048;
|
||||
const double f0 = 1.0 / 96.0; // period 96: zero crossings dwell ~2 frames
|
||||
SampleData s = tailSine(frames, f0, 60);
|
||||
s.play.pitchEngine = PitchEngine::Preserve;
|
||||
s.play.adsr = flatAdsr();
|
||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||
VoiceEngine eng(1, km, /*preserveCap=*/0, static_cast<std::int64_t>(w));
|
||||
eng.noteOn(72, 127); // +1 octave up-shift (tap sweeps the whole ring)
|
||||
std::vector<AudioSample> out;
|
||||
eng.render(out, frames); // voice runs to its natural end (no loop)
|
||||
// Pre-fix: the tap crossed the declared-valid zero pad repeatedly — quiet runs of 150+
|
||||
// frames. Post-fix every relocation stays inside the real filled span; only sine zero
|
||||
// crossings dip below the threshold.
|
||||
CHECK(worstQuietRun(out, 0, frames, 0.05) < 30);
|
||||
CHECK(blockPeak(out, 0, frames) > 0.5); // and it genuinely played at full level
|
||||
}
|
||||
|
||||
int main() {
|
||||
testChromaticSingleRoot();
|
||||
testZonedRangesBoundaries();
|
||||
@@ -2584,6 +2646,11 @@ int main() {
|
||||
testPreserveTailReleaseContinuous();
|
||||
testPreserveTriggerTailGapFree();
|
||||
|
||||
// Q-W0 T1-03 — the prime is bounded by the playable span (Trigger playEnd / sample end),
|
||||
// with an immediate tail freeze on sub-window spans.
|
||||
testPreservePrimeStopsAtTriggerPlayEnd();
|
||||
testPreserveSubWindowSampleNoZeroPadInRing();
|
||||
|
||||
if (g_fail == 0) {
|
||||
std::printf("all sampler_core tests passed\n");
|
||||
return 0;
|
||||
|
||||
Reference in New Issue
Block a user