Play cross-rate captures at their own pitch and length

The voice now converts the file's rate to the host's at note-on instead of assuming they match. Stage times resolve at the host rate, the filter solves at it, and the bake follows. Same-rate renders are unchanged bit for bit.
This commit is contained in:
2026-09-25 13:44:39 -04:00
parent 9ff34b0a9e
commit ec60cf78c6
25 changed files with 749 additions and 137 deletions
+30
View File
@@ -10,6 +10,7 @@
// a variable-length feed loop RT-safe.
// 4. the clamp: out-of-range folds to the bounds, unusable input folds to unity (never to a
// silent stall or a quarter-speed surprise).
// 4b. the source/host rate ratio multiplies the CLAMPED rate, unclipped by the clamp itself.
// 5. the sustain loop wraps the cursor and never lets it leave [start, end) — "loop the
// source" holds at every rate, including one that steps over the loop end.
@@ -109,6 +110,34 @@ static void testRateClamp() {
CHECK(d.due(50.0) <= kMaxFeedPerFrame);
}
// --- 4b. The source/host rate ratio scales the feed AFTER the musical clamp. ---
static void testSourceRateRatioScalesTheClampedRate() {
const ResolvedLoop lp = noLoop();
// 96k source in a 44.1k host: past the musical ceiling on its own, and not clipped by it.
const double ratio = 96000.0 / 44100.0;
for (double rate : {0.5, 1.0, 2.0, 50.0}) {
StretchCursor c;
c.start(0, ratio);
const std::int64_t outFrames = 100000;
std::int64_t worst = 0;
for (std::int64_t i = 0; i < outFrames; ++i) {
const std::int64_t due = c.due(rate);
if (due > worst) worst = due;
for (std::int64_t k = 0; k < due; ++k) (void)c.next(lp);
}
const double expected = static_cast<double>(outFrames) * clampStretchRate(rate) * ratio;
CHECK(std::fabs(static_cast<double>(c.frame()) - expected) <= 1.0);
CHECK(static_cast<double>(worst) <= std::ceil(kStretchRateMax * ratio));
}
// A ratio of exactly 1.0 is the unity schedule, frame for frame.
StretchCursor u;
u.start(7, 1.0);
for (std::int64_t i = 0; i < 1000; ++i) {
CHECK(u.due(1.0) == 1);
CHECK(u.next(lp) == 7 + i);
}
}
// --- 5. The loop wraps the SOURCE cursor, at every rate. ---
static void testCursorStaysInsideTheLoopSpan() {
const ResolvedLoop lp = loopSpan(1000, 1040); // a 40-frame loop: rate 4 steps 10% of it
@@ -144,6 +173,7 @@ int main() {
testTotalConsumedTracksTheRate();
testFeedPerOutputFrameIsBounded();
testRateClamp();
testSourceRateRatioScalesTheClampedRate();
testCursorStaysInsideTheLoopSpan();
if (g_fail == 0) {