Γ-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:
+128
-38
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user