Bake window: derive it from the rate the voice actually reads at, so a dialled Rate or downward Pitch no longer truncates the file

This commit is contained in:
2026-08-02 06:30:59 -04:00
parent 248f2f3842
commit cbe2369037
16 changed files with 609 additions and 74 deletions
+177
View File
@@ -3251,6 +3251,179 @@ static void testRateScalesTheLoopPeriodWithoutMovingItsStoredFrames() {
}
}
// Preserve's half of the loop claim, and it is the OPPOSITE of the Varispeed one — written down
// here because the obvious extension of the test above is WRONG. Preserve consumes the loop at
// `rate` source frames per output frame, so the TRAVERSAL scales (the feed-side witness in
// testPreserveStretchLoopsTheSourceSpan measures that directly); what the listener hears does
// not, because holding the source's period while its duration changes is the definition of the
// engine. Measured with a ring long enough to hold the whole loop, so the reading is the design
// property rather than splice cadence — at shorter rings the same fixture measured 3064 and 4130
// frames at rate 0.5 (windows 1024 and 2048), neither of which is the 8000 a scaling period
// would give either.
static void testPreserveHoldsTheLoopsAudiblePeriodWhileRateMovesItsTraversal() {
constexpr std::int64_t kLoopStart = 4000;
constexpr std::int64_t kLoopEnd = 8000;
SampleData base;
base.frames.assign(20000, 0.0f);
for (std::int64_t i = kLoopStart; i < kLoopEnd; ++i) {
base.frames[static_cast<std::size_t>(i)] =
static_cast<float>(i - kLoopStart) / static_cast<float>(kLoopEnd - kLoopStart);
}
base.rootNote = 60;
base.startFrame = kLoopStart;
base.loop = SampleLoop{true, kLoopStart, kLoopEnd};
base.play.adsr = flatAdsr();
base.play.pitchEngine = PitchEngine::Preserve;
auto sawPeriod = [](const std::vector<AudioSample>& v) {
double sum = 0.0;
std::size_t prev = 0, count = 0;
for (std::size_t i = 1; i < v.size(); ++i) {
if (v[i - 1] <= 0.5f && v[i] > 0.5f) {
if (count > 0) sum += static_cast<double>(i - prev);
prev = i;
++count;
}
}
return count > 1 ? sum / static_cast<double>(count - 1) : 0.0;
};
for (double rate : {1.0, 0.5, 2.0}) {
SampleData s = base;
s.play.playRate = rate;
Voice v;
v.presizePreserveShifters(8192); // > the 4000-frame loop
v.start(60, 127, s, /*declickTakeover=*/false, rate);
std::vector<AudioSample> out(40000, 0.0f);
for (std::size_t i = 0; i < out.size(); ++i) out[i] = v.renderFrame();
const double period = sawPeriod(out);
CHECK(approx(period, 4000.0, 40.0));
if (!approx(period, 4000.0, 40.0)) std::printf(" rate %.2f period %.1f\n", rate, period);
// And the marks the waveform draws are source-frame FACTS the engine only ever reads.
CHECK(s.loop.start == kLoopStart);
CHECK(s.loop.end == kLoopEnd);
CHECK(s.startFrame == kLoopStart);
}
}
// The other half of the same rule, which nothing asserted: a drawn contour is a pure function of
// NORMALIZED sample position, so it follows the read head and its wall-clock shape scales by
// 1/rate — under BOTH engines, since both advance that head at the rate. Measured as the output
// frame the contour's own half-way point arrives on, which is what a listener hears move.
static void testADrawnContourScalesWithRateInBothEngines() {
for (PitchEngine eng : {PitchEngine::Varispeed, PitchEngine::Preserve}) {
double atUnity = 0.0;
for (double rate : {1.0, 0.5, 2.0}) {
SampleData s = dcSample(24000);
s.play.playMode = PlayMode::Trigger;
s.play.pitchEngine = eng;
s.play.playRate = rate;
s.play.ampSpline.mode = EnvMode::Spline;
s.play.ampSpline.contour = VelocityCurve::linear(); // 0 -> 1 across the sample
Voice v;
v.presizePreserveShifters(1024);
v.start(60, 127, s, /*declickTakeover=*/false, rate);
double halfway = 0.0;
for (std::size_t i = 0; i < 80000 && v.active(); ++i) {
const double y = static_cast<double>(v.renderFrame());
if (halfway == 0.0 && y > 0.5) halfway = static_cast<double>(i);
}
CHECK(halfway > 0.0);
if (rate == 1.0) atUnity = halfway;
// 12000 source frames in at unity; twice as many output frames at half rate.
else CHECK(approx(halfway, atUnity / rate, atUnity * 0.02));
if (rate != 1.0 && !approx(halfway, atUnity / rate, atUnity * 0.02)) {
std::printf(" eng %d rate %.2f: halfway %.0f, wanted %.0f\n",
static_cast<int>(eng), rate, halfway, atUnity / rate);
}
}
}
}
// Pitch is the same multiply as Rate under Varispeed, so the same rule binds it: a staged stage
// time is OF THE PERFORMANCE and does not scale. The AHD is the case that can go wrong, since it
// is evaluated at the SOURCE offset — which a Pitch offset advances faster or slower. Under
// Preserve the offset never touches the read, so the same attack lands on the same frame there
// for a different reason; asserted in both so the compensation cannot be applied to the wrong
// engine. Key-tracking is deliberately NOT compensated, and the last block pins that too.
static void testAPitchOffsetLeavesTheStagedAttackWallClockUnderVarispeed() {
constexpr std::int64_t kAttack = 2000;
SampleData base = dcSample(48000);
base.play.playMode = PlayMode::Trigger;
base.play.trigAhd = AhdParams{kAttack, 0, 1.0, util::kCurveNeutral, util::kCurveNeutral};
const auto attackFrame = [](const SampleData& s, int note) {
Voice v;
v.presizePreserveShifters(1024);
v.start(note, 127, s, /*declickTakeover=*/false, s.play.playRate);
for (std::size_t i = 0; i < 200000 && v.active(); ++i) {
if (static_cast<double>(v.renderFrame()) > 0.99) return static_cast<double>(i);
}
return -1.0;
};
for (PitchEngine eng : {PitchEngine::Varispeed, PitchEngine::Preserve}) {
for (double semis : {-12.0, -5.0, 0.0, 7.0, 12.0}) {
SampleData s = base;
s.play.pitchEngine = eng;
s.play.pitchOffsetSemitones = semis;
const double got = attackFrame(s, 60);
CHECK(approx(got, static_cast<double>(kAttack), 40.0));
if (!approx(got, static_cast<double>(kAttack), 40.0)) {
std::printf(" eng %d pitch %+.1f st: attack completed at %.0f\n",
static_cast<int>(eng), semis, got);
}
}
}
// Key-tracking stays UNCOMPENSATED on purpose — it is a shipped sound, and compensating it
// would move every note off the root. An octave up therefore completes the attack in half
// the output frames, which is exactly the behaviour Pitch above does not have.
SampleData vari = base;
vari.play.pitchEngine = PitchEngine::Varispeed;
CHECK(approx(attackFrame(vari, 72), static_cast<double>(kAttack) / 2.0, 40.0));
}
// --- The Varispeed null case, baselined so the NEXT track's claim is measured. ---
// Unlike the Preserve hashes above, these were captured from THIS commit rather than witnessed
// against the pre-track one, and that difference is the whole reason the comment says so: the
// pre-track equality is proved structurally instead, and cheaply — at Rate 100 % and Pitch 0 st
// both new factors of recomputeBaseRatio's product are EXACTLY 1.0 (semitoneRatio short-circuits
// at zero; the clamp returns 1.0 for 1.0), and multiplying a double by 1.0 is bit-exact, so the
// read increment is the pre-track engine's own. What these constants add is a witness for the
// track AFTER this one. A change here is a change to what every already-saved project sounds
// like — re-derive the cause before re-baselining.
static void testVarispeedUnityRateAndPitchAreBitIdenticalToTheirBaseline() {
const std::size_t n = 6000;
struct Case { int note; bool stereo; bool loop; std::uint64_t hashL; std::uint64_t hashR; };
const Case cases[] = {
{60, false, false, 5964955069002935931ull, 0ull}, // on root: unity read
{67, false, false, 134881748704183217ull, 0ull}, // +7 st
{55, false, false, 11914283967735558216ull, 0ull}, // -5 st
{67, true, true, 11674273643338193955ull, 15241091931688620298ull}, // stereo + loop
};
for (const Case& c : cases) {
SampleData s = stretchProbeSample(4000, c.stereo);
s.play.pitchEngine = PitchEngine::Varispeed;
if (c.loop) {
s.loop.hasLoop = true;
s.loop.start = 1200;
s.loop.end = 3600;
s.loopCrossfadeFrames = 256;
}
std::vector<AudioSample> l(n), r(c.stereo ? n : 0);
renderVoice(s, c.note, /*rate=*/1.0, /*window=*/2205, c.stereo, l, r);
const std::uint64_t hl = hashStream(l);
CHECK(hl == c.hashL);
if (hl != c.hashL) std::printf(" varispeed note %d L hash %lluull\n", c.note, hl);
if (c.stereo) {
const std::uint64_t hr = hashStream(r);
CHECK(hr == c.hashR);
if (hr != c.hashR) std::printf(" varispeed note %d R hash %lluull\n", c.note, hr);
}
}
}
// The asymmetry the spec is explicit about: a contour is OF THE SAMPLE and scales with Rate, a
// staged envelope is OF THE PERFORMANCE and does not. Trigger's AHD is the case that could go
// wrong — it is evaluated at the SOURCE offset, which advances at the rate — so its stage frames
@@ -3639,6 +3812,10 @@ int main() {
testKeyTrackRateAndPitchOffsetResolveToOneMultiply();
testPreserveRoutesRateToDurationAndTheOffsetToPitch();
testRateScalesTheLoopPeriodWithoutMovingItsStoredFrames();
testPreserveHoldsTheLoopsAudiblePeriodWhileRateMovesItsTraversal();
testADrawnContourScalesWithRateInBothEngines();
testAPitchOffsetLeavesTheStagedAttackWallClockUnderVarispeed();
testVarispeedUnityRateAndPitchAreBitIdenticalToTheirBaseline();
testStagedStageTimesDoNotScaleWithRateWhileTheSpanDoes();
testPreserveStretchSpeaksOnFrameZeroAtEveryRate();
testPreserveStretchLoopsTheSourceSpan();