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
+128
View File
@@ -64,6 +64,19 @@ double peakAt(const BakeAudio& audio, std::int64_t from, std::int64_t to) {
return peak;
}
// The last frame of the file that carries any signal at all — where the voice ACTUALLY stopped.
// A measurement of the engine, never a second evaluation of the derivation under test. -1 when
// the render is silent throughout.
std::int64_t lastSoundingFrame(const BakeAudio& audio) {
for (std::int64_t f = audio.frameCount() - 1; f >= 0; --f) {
if (std::fabs(static_cast<double>(
audio.interleaved[static_cast<std::size_t>(f * audio.channelCount)])) > kSilence) {
return f;
}
}
return -1;
}
// The derived program, optionally lengthened: `extraMs` widens ONLY the end offset (the same
// sound, a longer window). It leaves the derivation itself untouched, which is what makes the
// comparison a measurement of the derived end rather than of a second derivation.
@@ -92,6 +105,20 @@ std::int64_t derivedFrames(const SampleData& s, Division hold = oneBar()) {
return plan ? plan->totalFrames : -1;
}
// Where the dialed sound stops when NOTHING cuts it: the same sound programmed with a
// deliberately long note and a window to match. This is the reference a derived window is
// judged against, and it has to be measured rather than recomputed — an under-derived Gate
// window truncates by releasing the note EARLY, which leaves no signal outside the file at all
// and so is invisible to "nothing past the end".
std::int64_t freeRunningEnd(const SampleData& s, double heldSeconds) {
NoteProgram p = defaultBakeProgram(s, kRate, oneBar(), Velocity::of(100));
p.length = lengthOfSeconds(heldSeconds);
p.end = EndOffset(offsetFromMs(200.0));
const std::optional<BakePlan> plan = planOf(p);
if (!plan) { std::printf("FAIL: fixture reference window refused\n"); ++g_fail; return -1; }
return lastSoundingFrame(renderBake(s, *plan, kUnity));
}
// The last frame of the file, which is where a hard cut shows up.
double lastFrameLevel(const BakeAudio& audio) {
return audio.frameCount() > 0 ? peakAt(audio, audio.frameCount() - 1, audio.frameCount())
@@ -337,6 +364,107 @@ int main() {
CHECK(derivedFrames(staged) == 12000 + kPad);
}
// ============================ RATE AND PITCH ====================================
// The one judgement every case below makes: the derived window holds the WHOLE free-running
// sound (the derived render stops exactly where the uncut one does), and it is exactly
// enough rather than merely long. `heldSeconds` only has to exceed the free-running length.
const auto windowHoldsTheWholeNote = [&](const SampleData& s, double heldSeconds,
const char* what) {
const std::int64_t trueEnd = freeRunningEnd(s, heldSeconds);
const std::int64_t derived = derivedFrames(s);
const std::int64_t got = lastSoundingFrame(bakeWith(s, 0.0));
const bool held = trueEnd >= 0 && derived > trueEnd && got == trueEnd;
CHECK(held);
CHECK(held && derived - trueEnd <= kPad + 8);
if (!(held && derived - trueEnd <= kPad + 8)) {
std::printf(" %s: free-running end %lld, derived render end %lld, window %lld\n",
what, static_cast<long long>(trueEnd), static_cast<long long>(got),
static_cast<long long>(derived));
}
};
// --- Rate scales the window under BOTH engines, in both derived branches --------------
// Rate IS the read rate: Varispeed folds it into the read increment, Preserve feeds the
// stretcher at it. Either way a 50 % rate doubles how long the source takes to play out and
// a 200 % one halves it, so a window blind to Rate truncates by half at the slow end and
// prints a file of trailing silence at the fast one.
{
for (PitchEngine eng : {PitchEngine::Varispeed, PitchEngine::Preserve}) {
for (PlayMode mode : {PlayMode::Trigger, PlayMode::Gate}) {
for (double rate : {0.5, 0.75, 1.0, 1.5, 2.0}) {
SampleData s = dcSample(48000); // 1 s; 2 s at the slowest rate
s.play.playMode = mode;
s.play.pitchEngine = eng;
s.play.adsr.releaseFrames = 0;
s.play.playRate = rate;
char what[64];
std::snprintf(what, sizeof(what), "eng %d mode %d rate %.2f",
static_cast<int>(eng), static_cast<int>(mode), rate);
windowHoldsTheWholeNote(s, 3.0, what);
}
}
}
}
// --- A downward Pitch offset stretches the window under VARISPEED only ---------------
// It is a factor of the read increment there and a shifter transpose under Preserve, so the
// window follows it in one engine and not the other. Both must still hold the whole note.
{
SampleData s = dcSample(48000);
s.play.playMode = PlayMode::Trigger;
s.play.pitchEngine = PitchEngine::Varispeed;
s.play.pitchOffsetSemitones = -12.0; // half rate for the note's whole lifetime
CHECK(derivedFrames(s) == 96000 + kPad);
windowHoldsTheWholeNote(s, 3.0, "varispeed pitch -12");
SampleData p = s;
p.play.pitchEngine = PitchEngine::Preserve;
CHECK(derivedFrames(p) == 48000 + kPad); // the read rate never moved
windowHoldsTheWholeNote(p, 3.0, "preserve pitch -12");
// An UPWARD offset bounds nothing — the read only gets faster — so the window keeps the
// un-stretched span and the balance is trailing silence, on the same asymmetry the
// velocity->pitch term already takes.
SampleData up = s;
up.play.pitchOffsetSemitones = 12.0;
CHECK(derivedFrames(up) == 48000 + kPad);
const BakeAudio wideUp = bakeWith(up, /*extraMs=*/500.0);
CHECK(peakAt(wideUp, 48000 + kPad, wideUp.frameCount()) == 0.0);
}
// --- Rate and Pitch COMPOUND, because the voice folds them into one multiply ----------
{
SampleData s = dcSample(48000);
s.play.playMode = PlayMode::Trigger;
s.play.pitchEngine = PitchEngine::Varispeed;
s.play.playRate = 0.5;
s.play.pitchOffsetSemitones = -12.0; // together: a quarter-speed read
CHECK(derivedFrames(s) == 192000 + kPad);
windowHoldsTheWholeNote(s, 5.0, "varispeed rate 0.5 x pitch -12");
}
// --- Gate over a sustain loop is Hold's, and Rate does not touch it -------------------
// The note length there is the user's Hold in wall clock and the release is ticked per
// output frame, so neither term of the stretch applies — the one derived branch that must
// NOT move when Rate does.
{
SampleData s = dcSample(48000);
s.loop = SampleLoop{true, 0, 24000};
s.play.playMode = PlayMode::Gate;
s.play.adsr.releaseFrames = 4800;
CHECK(bakeWindowNeedsHold(s));
const std::int64_t unity = derivedFrames(s);
for (double rate : {0.5, 2.0}) {
SampleData r = s;
r.play.playRate = rate;
CHECK(derivedFrames(r) == unity);
}
}
// ============================== VELOCITY ========================================
// --- The bake renders at the velocity it is handed ----------------------------------