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
+94 -4
View File
@@ -211,7 +211,7 @@ static void testPitchEnvelopeHoldsPhaseAndGlidesDepth() {
PitchEnvParams longer = p;
longer.shape.decayFrames = 2000;
b.applyLive(longer); // decay doubled mid-decay
b.applyLive(100000, longer); // decay doubled mid-decay, same span
CHECK(a.tick() == b.tick()); // phi held: the semitone offset is unchanged this frame
// A depth move is a level step, so it glides rather than jumping: the first frame after
@@ -224,7 +224,7 @@ static void testPitchEnvelopeHoldsPhaseAndGlidesDepth() {
for (int i = 0; i < 400; ++i) { c.tick(); d.tick(); }
PitchEnvParams noDepth = p;
noDepth.peakSemitones = 0.0;
c.applyLive(noDepth); // depth to zero mid-decay
c.applyLive(100000, noDepth); // depth to zero mid-decay
CHECK(c.tick() == d.tick());
// ...and it does eventually reach the new depth rather than staying put.
for (int i = 0; i < 400; ++i) c.tick();
@@ -259,7 +259,7 @@ static void testPitchEnvelopeHoldStagePlaysAndHoldsPhase() {
for (int i = 0; i < 300; ++i) f.tick();
PitchEnvParams wider = p;
wider.shape.holdFraction = 1.0;
f.applyLive(wider);
f.applyLive(1000, wider);
CHECK(f.tick() == 12.0);
for (int i = 0; i < 1200; ++i) f.tick();
CHECK(f.tick() == 0.0);
@@ -307,7 +307,7 @@ static void testAFreshPitchEnvelopeTakesTheNewTimesOutright() {
PitchEnvParams dialled = stale;
dialled.peakSemitones = 12.0;
dialled.shape.decayFrames = 1000;
env.snapLive(dialled);
env.snapLive(100000, dialled);
CHECK(env.tick() == 12.0); // at the top of the new decay leg, not past the envelope
for (int i = 0; i < 499; ++i) env.tick();
CHECK(std::fabs(env.tick() - 6.0) < 1e-12);
@@ -850,6 +850,94 @@ static void testAPitchOffsetChangeMovesTheSoundingNoteInBothEngines() {
}
}
// --- The live Pitch offset reaches the note's TIME domains, not only its pitch -------------
// A block published BEFORE the note starts is the snapLive path, and the snapshot's own copy of
// the offset is deliberately stale there — so this is where a Pitch offset has to be in hand
// already when the note's envelopes are fitted against the read rate. Answers how many output
// frames the voice sounded for, to a 256-frame block.
static std::size_t soundingBlocksWithPublishedPitch(SampleData& s, double offsetSemis,
std::size_t capFrames) {
LiveParams block;
LiveValues v = foldLive(s.play); // s.play keeps its own (zero) offset: the stale copy
v.pitchOffsetSemitones = offsetSemis;
block.publish(v);
s.live = &block;
VoiceEngine engine(1, s, /*preserveVoiceCap=*/0, /*preserveWindowFrames=*/2048);
engine.noteOn(60, 127);
std::vector<AudioSample> out;
std::size_t life = 0;
while (out.size() < capFrames && engine.activeVoiceCount() > 0) {
engine.render(out, 256);
life = out.size();
}
return life;
}
// Under Varispeed the Pitch offset is a factor of the read increment, and the staged AHD is
// evaluated at the SOURCE offset that increment advances — so its stage frames are fitted to the
// offset the note will ACTUALLY play at, exactly as they are to Rate. The attack therefore
// completes on the same output frame at every offset. Fitting against the snapshot's stale zero
// instead is what this catches.
static void testAPublishedPitchOffsetLeavesTheStagedAttackWallClock() {
constexpr std::int64_t kAttack = 2000;
for (double semis : {-12.0, 0.0, 12.0}) {
SampleData s;
s.frames.assign(96000, 1.0f); // DC: the output IS the amp envelope
s.sampleRate = kRate;
s.rootNote = 60;
s.play.playMode = PlayMode::Trigger;
s.play.pitchEngine = PitchEngine::Varispeed;
s.play.trigAhd = AhdParams{kAttack, 0, 1.0, util::kCurveNeutral, util::kCurveNeutral};
LiveParams block;
LiveValues v = foldLive(s.play);
v.pitchOffsetSemitones = semis;
block.publish(v);
s.live = &block;
VoiceEngine engine(1, s, /*preserveVoiceCap=*/0, /*preserveWindowFrames=*/2048);
engine.noteOn(60, 127);
std::vector<AudioSample> out;
engine.render(out, 8000);
std::size_t reachedFull = 0;
for (std::size_t i = 0; i < out.size(); ++i) {
if (out[i] > 0.99f) { reachedFull = i; break; }
}
const bool ok = reachedFull > 0 &&
std::fabs(static_cast<double>(reachedFull) -
static_cast<double>(kAttack)) < 40.0;
CHECK(ok);
if (!ok) std::printf(" pitch %+.1f st: attack completed at %zu\n", semis, reachedFull);
}
}
// The pitch envelope's SPAN is a wall-clock duration converted from the same read rate, so it
// follows the published offset too. Read out as the note's LIFETIME: the envelope's depth
// cancels the offset while it holds, so the read runs at unity for the hold and at the offset
// ratio after it — which makes the lifetime a direct readout of where the hold ended.
// 12000 source frames, offset -12 st (read at 0.5): the span is 24000 output frames, its
// half-span hold is 12000 of them at unity, and the source is exhausted exactly there.
// A span fitted to the stale zero offset is 12000, holds for 6000, and the remaining 6000
// source frames then take 12000 more output frames — 18000 in total.
static void testAPublishedPitchOffsetRefitsThePitchEnvelopeSpan() {
SampleData s;
s.frames.assign(12000, 1.0f);
s.sampleRate = kRate;
s.rootNote = 60;
s.play.playMode = PlayMode::Trigger;
s.play.pitchEngine = PitchEngine::Varispeed;
s.play.trigAhd = AhdParams{0, 0, 1.0, util::kCurveNeutral, util::kCurveNeutral};
s.play.pitchEnv.enabled = true;
s.play.pitchEnv.peakSemitones = 12.0; // cancels the -12 offset while it holds
s.play.pitchEnv.shape.attackFrames = 0;
s.play.pitchEnv.shape.decayFrames = 0;
s.play.pitchEnv.shape.holdFraction = 0.5;
const std::size_t life = soundingBlocksWithPublishedPitch(s, -12.0, 60000);
CHECK(life > 11000 && life < 13000);
if (!(life > 11000 && life < 13000)) std::printf(" refit span: life %zu\n", life);
}
// --- What stays latched at note-on -------------------------------------------------------
static void testPitchRatioAndVelocityGainStayLatched() {
@@ -991,6 +1079,8 @@ int main() {
testOneBlockServesTwoIndependentObservers();
testARateChangeSpareTheSoundingNoteAndReachesTheNextOne();
testAPitchOffsetChangeMovesTheSoundingNoteInBothEngines();
testAPublishedPitchOffsetLeavesTheStagedAttackWallClock();
testAPublishedPitchOffsetRefitsThePitchEnvelopeSpan();
testPitchRatioAndVelocityGainStayLatched();
testVelocityGainSurvivesAHostilePublishThatReallyLands();
if (g_fail == 0) std::printf("live_delivery tests passed\n");