Γ-W1-T5 remediation: narrow the rate-bound claim, fix baseline/measurement provenance, correct §2.4 framing

Re-derives the splice-cadence inequality and adds a corner probe that FAILS at
P=500 by design, pending a ruling. Names the baseline commit and harness edit,
fixes measurement methodology, corrects the Trigger-AHD/rate coupling framing.
This commit is contained in:
2026-08-01 20:07:59 -04:00
parent 589a8e078b
commit ae59e9b70d
5 changed files with 196 additions and 49 deletions
+33
View File
@@ -737,6 +737,38 @@ static void testStretchAndShiftComposeSafely() {
}
}
// The [0.5, 2.0] rate bound (time_stretch.h) narrows the splice-cadence failure onto the
// source fundamental rather than eliminating it. At rate 2.0, shift 0.25 (-24 st) — both
// inside the shipped range — the header's own derivation puts the safe-source floor at a
// period of 315 frames (~140 Hz @ 44.1k): testStretchAndShiftComposeSafely's probe period of
// 196.37 frames (~225 Hz) sits ABOVE that floor, so it passes because of the probe, not
// because of headroom. This probe sits BELOW the floor on purpose, asserting the corner
// rather than assuming it. A failure here is the inequality's PREDICTED outcome, not a
// defect this test exists to chase — report it, don't retune the tolerance to hide it.
static void testStretchCadenceBelowSafeFloorAtRate2ShiftQuarter() {
const std::int64_t w = 2205;
const double rate = 2.0;
const double shift = std::pow(2.0, -24.0 / 12.0); // 0.25
for (double period : {500.0, 600.0, 700.0}) {
const double f0 = 1.0 / period;
const std::size_t srcLen = 400000;
std::vector<AudioSample> src(srcLen);
for (std::size_t i = 0; i < srcLen; ++i) {
src[i] = static_cast<AudioSample>(std::sin(2.0 * kPi * f0 * static_cast<double>(i)));
}
const std::size_t outFrames = 60000;
const std::vector<double> out = runStretch(src, w, rate, shift, outFrames, nullptr);
for (double v : out) CHECK(std::isfinite(v));
const double p = periodIn(out, 20000, 50000);
const double want = period / shift;
const bool ok = approx(p, want, want * 0.12);
std::printf(" [floor probe] period %.0f (rate 2.0, -24 st): observed %.2f want %.2f "
"-> %s\n", period, p, want, ok ? "held" : "FAILED (predicted by the "
"inequality in time_stretch.h)");
CHECK(ok);
}
}
// The two new entry points on a shifter that was never configured (a Varispeed voice's) —
// neither may touch the empty ring.
static void testStretchEntryPointsOnPassThrough() {
@@ -758,6 +790,7 @@ int main() {
testStereoLinkedLagSharedSchedule();
testStretchMovesDurationNotPitch();
testStretchAndShiftComposeSafely();
testStretchCadenceBelowSafeFloorAtRate2ShiftQuarter();
testStretchEntryPointsOnPassThrough();
if (g_fail == 0) {
+128 -38
View File
@@ -20,11 +20,11 @@
#include "../src/core/instrument/engine/voice_engine.h"
#include <algorithm>
#include <chrono>
#include <cmath>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <ctime>
#include <vector>
using namespace reasampler;
@@ -2947,11 +2947,13 @@ static void renderVoice(const SampleData& s, int note, double rate, std::int64_t
}
// --- The null case, asserted against a baseline the SHIPPED engine produced. ---
// The four constants below were captured by running this same function against the
// pre-stretch build (phase-g, before the rate seam existed) and printing the hashes; they are
// therefore a witness that the generalized read path reproduces the shipped Preserve output
// bit for bit at rate 1.0, not a self-consistency check. A change here is a change to what
// every already-saved project sounds like — re-derive the cause before re-baselining.
// The four constants below are a witness against `phase-g`'s tip, commit 0a7778b — the last
// commit before this track's rate seam — not a self-consistency check. To re-derive: check
// out 0a7778b, add this file's stretchProbeSample/hashStream/renderVoice/test body to it, and
// drop the trailing `, rate` argument from renderVoice's `v.start(...)` call (0a7778b's
// Voice::start has no 5th parameter) — then build, run, and print the hashes. A change here is
// a change to what every already-saved project sounds like — re-derive the cause before
// re-baselining.
static void testPreserveUnityRateIsBitIdenticalToTheShippedRead() {
const std::int64_t w = 2205; // the product window at 44.1k
const std::size_t n = 6000;
@@ -3084,11 +3086,16 @@ static void testPreserveStretchSpeaksOnFrameZeroAtEveryRate() {
// The primed ring parks the tap ON the start frame, so output frame 0 is source
// frame `startFrame` exactly — at every rate and every transposition. A stretcher
// that buffered a window before speaking would fail here, which is the whole point.
// This bit-exact check is what actually carries "no first-frame smear"; the loop
// below is a coarser, complementary DROPOUT detector (see its own comment).
CHECK(first == s.frames[500]);
// ...and it keeps speaking: no first-window dip while the schedule settles. The
// 256-frame measuring window spans most of a period even at the lowest note tested
// (-12 st stretches the probe's 196-frame period to 393), so a continuous tone
// peaks well above the floor in every one of them and only a real gap can sink it.
// ...and it keeps speaking: no first-window DROPOUT while the schedule settles.
// `lo > 0.5` over twenty 256-frame peak windows catches a gap of roughly a window,
// but a smeared or phase-scrambled first window can still peak above 0.5 and pass
// here — it cannot see that; the CHECK above is what does. The 256-frame measuring
// window spans most of a period even at the lowest note tested (-12 st stretches
// the probe's 196-frame period to 393), so a continuous tone peaks well above the
// floor in every one of them and only a real gap can sink it.
double lo = 1e9;
for (int i = 0; i < 20; ++i) {
double peak = 0.0;
@@ -3130,14 +3137,84 @@ static void testPreserveStretchLoopsTheSourceSpan() {
CHECK(approx(sum / 200.0, 0.5, 0.05));
}
}
// The two assertions above hold even if stretchRate_ were ignored outright — the loop's
// constant content proves nothing about cadence. A one-time marker AFTER the primed window
// but BEFORE the loop start is the source-frame witness that the feed genuinely consumes
// source AT THE RATE: note-on primes the ring with the first `window` source frames up
// front (played back at 1 frame/output-frame, independent of rate — a marker inside that
// span was measured landing at a FIXED output frame at every rate, confirming it is not a
// rate witness). Past it, new content only enters the ring via the ongoing due()-scheduled
// feed, at `rate` source frames per output frame on average: the marker's single output
// appearance lands at `window + (markerFrame - window) / rate` output frames. Note == root
// (shift == 1.0), isolating the rate's effect from the pitch engine's own transposition.
//
// Excludes rate 2.0: at shift 1.0 that is drift = |rate-shift| = 1.0 exactly, and this
// geometry's own splice trigger (0.75x window output frames from note-on, measured) fires
// BEFORE the primed span even finishes playing back (< window frames) whenever drift >=
// ~0.75 — so no marker placed "past the prime" can be reached before a splice relocates
// the tap first. Confirmed by measurement, not assumed: a rate-2.0 attempt at this marker
// came back with the tap having moved on (no witness value in the output at all). The
// write-side consumption-at-the-rate claim at every rate, splice-immune because it never
// goes through the shifter, is what test_time_stretch.cpp's StretchCursor tests assert.
//
// A marker placed INSIDE the steady-state loop instead would NOT show rate-dependence
// either: once ring-resident, the read tap's own pace is governed by SHIFT alone ("shift
// the output" — ratio_ advances posA_ every output frame unconditionally), so it revisits
// every loopLength ring slots at 1 slot/output-frame regardless of how fast the writer
// filled them — confirmed by measurement (median recurrence gap 200 frames at rate 0.5,
// 1.0 AND 2.0 alike, for a 200-frame loop). Rate governs the feed/splice cadence, not the
// loop's own output period, once its content is already in the ring.
for (double rate : {0.5, 1.0}) {
SampleData s;
s.frames.assign(1000, 0.0f);
for (int i = 300; i < 900; ++i) s.frames[i] = 0.5f;
s.frames[650] = 1.0f; // past the 600-frame primed span, before the loop at 700
s.rootNote = 60;
s.sampleRate = 48000;
s.loop.hasLoop = true;
s.loop.start = 700;
s.loop.end = 900;
s.play.adsr = flatAdsr();
s.play.pitchEngine = PitchEngine::Preserve;
Voice v;
v.presizePreserveShifters(600);
v.start(60, 127, s, /*declickTakeover=*/false, rate); // root note: shift == 1.0
const std::size_t total = 3000;
std::vector<AudioSample> out(total);
for (std::size_t i = 0; i < total; ++i) out[i] = v.renderFrame();
std::size_t hitAt = 0;
for (std::size_t i = 0; i < total; ++i) {
if (out[i] > 0.7f) { hitAt = i; break; }
}
CHECK(hitAt > 0);
const double want = 600.0 + (650.0 - 600.0) / rate;
if (!approx(static_cast<double>(hitAt), want, want * 0.15 + 5.0)) {
std::printf(" rate %.2f: marker at frame %zu want %.2f\n", rate, hitAt, want);
}
CHECK(approx(static_cast<double>(hitAt), want, want * 0.15 + 5.0));
}
}
// --- The 32-voice measurement gate. Asserts correctness; PRINTS the cost, which is the
// number reported for the algorithm decision (meaningful only in a Release build). ---
// number reported for the algorithm decision (meaningful only in a Release build).
//
// Methodology: std::chrono::steady_clock (not std::clock() — a single wall-clock diff has no
// warm-up and no spread), kWarmupReps discarded, kTimedReps repetitions per rate, median +
// [min, max] reported. secs is wall-clock for 1.0 s of audio on ONE thread with no other work
// scheduled onto it, so 100*secs is % of REALTIME consumed — not "% of one core" (that would
// additionally claim core-pinned exclusivity this benchmark never establishes).
//
// A separate, one-off Release A/B (unity-now vs the pre-stretch build at commit 0a7778b, same
// methodology, standalone harness outside this tree) found the two statistically
// indistinguishable at ~79-82 ns/voice/frame; that is a point-in-time finding to re-derive if
// this path changes materially, not a hardcoded regression bound here. ---
static void testPreserveStretchThirtyTwoVoicesHoldUp() {
const std::int64_t w = 2205; // the product window at 44.1k
const std::size_t blockFrames = 44100; // one second of audio
const std::size_t voiceCount = 32;
const int kWarmupReps = 2;
const int kTimedReps = 7;
SampleData s = stretchProbeSample(200000, true);
s.loop.hasLoop = true; // held notes: all 32 sound for the whole run
s.loop.start = 40000;
@@ -3147,36 +3224,49 @@ static void testPreserveStretchThirtyTwoVoicesHoldUp() {
// 1.0 is the reference: it is the cost the shipped Preserve read already carries, so the
// two stretched rows are read as a delta against it rather than in isolation.
for (double rate : {1.0, 0.5, 2.0}) {
std::vector<Voice> voices(voiceCount);
for (std::size_t i = 0; i < voiceCount; ++i) {
voices[i].presizePreserveShifters(w);
voices[i].start(48 + static_cast<int>(i), 100, s, /*declickTakeover=*/false, rate);
}
const std::clock_t t0 = std::clock();
double guard = 0.0;
std::size_t sounding = 0;
for (std::size_t f = 0; f < blockFrames; ++f) {
AudioSample l = 0.0f, r = 0.0f;
std::vector<double> nsPerVoiceFrame;
nsPerVoiceFrame.reserve(kTimedReps);
for (int rep = 0; rep < kWarmupReps + kTimedReps; ++rep) {
std::vector<Voice> voices(voiceCount);
for (std::size_t i = 0; i < voiceCount; ++i) {
AudioSample a = 0.0f, b = 0.0f;
voices[i].renderFrameStereo(a, b);
l += a;
r += b;
voices[i].presizePreserveShifters(w);
voices[i].start(48 + static_cast<int>(i), 100, s, /*declickTakeover=*/false,
rate);
}
const auto t0 = std::chrono::steady_clock::now();
double guard = 0.0;
std::size_t sounding = 0;
for (std::size_t f = 0; f < blockFrames; ++f) {
AudioSample l = 0.0f, r = 0.0f;
for (std::size_t i = 0; i < voiceCount; ++i) {
AudioSample a = 0.0f, b = 0.0f;
voices[i].renderFrameStereo(a, b);
l += a;
r += b;
}
guard += static_cast<double>(l) + static_cast<double>(r);
CHECK(std::isfinite(l) && std::isfinite(r));
}
const double secs =
std::chrono::duration<double>(std::chrono::steady_clock::now() - t0).count();
for (std::size_t i = 0; i < voiceCount; ++i) {
if (voices[i].active()) ++sounding;
}
CHECK(sounding == voiceCount); // all 32 held the whole second (the loop kept them up)
CHECK(std::fabs(guard) > 0.0); // ...and genuinely produced audio
if (rep >= kWarmupReps) {
nsPerVoiceFrame.push_back(secs * 1e9 / (static_cast<double>(blockFrames) *
static_cast<double>(voiceCount)));
}
guard += static_cast<double>(l) + static_cast<double>(r);
CHECK(std::isfinite(l) && std::isfinite(r));
}
const double secs = static_cast<double>(std::clock() - t0) / CLOCKS_PER_SEC;
for (std::size_t i = 0; i < voiceCount; ++i) {
if (voices[i].active()) ++sounding;
}
CHECK(sounding == voiceCount); // all 32 held the whole second (the loop kept them up)
CHECK(std::fabs(guard) > 0.0); // ...and genuinely produced audio
std::printf(" [measure] 32 stereo Preserve voices @ rate %.2f: %.3f s wall for 1.0 s "
"audio (%.1f%% of one core, %.1f ns/voice/frame)\n",
rate, secs, 100.0 * secs,
secs * 1e9 / (static_cast<double>(blockFrames) *
static_cast<double>(voiceCount)));
std::sort(nsPerVoiceFrame.begin(), nsPerVoiceFrame.end());
const double medianNs = nsPerVoiceFrame[nsPerVoiceFrame.size() / 2];
const double secsAtMedian =
medianNs * static_cast<double>(blockFrames) * static_cast<double>(voiceCount) / 1e9;
std::printf(" [measure] 32 stereo Preserve voices @ rate %.2f: median %.1f ns/voice/"
"frame [%.1f .. %.1f] over %d reps (%.1f%% of realtime at the median)\n",
rate, medianNs, nsPerVoiceFrame.front(), nsPerVoiceFrame.back(), kTimedReps,
100.0 * secsAtMedian);
}
}