Bound the automation hold to the window the model has not caught up on, and make that authority model stated, enforced and tested

This commit is contained in:
2026-08-02 17:16:02 -04:00
parent de5654fb6f
commit 1fd38bbd57
35 changed files with 1155 additions and 302 deletions
+108 -2
View File
@@ -938,6 +938,106 @@ static void testAPublishedPitchOffsetRefitsThePitchEnvelopeSpan() {
if (!(life > 11000 && life < 13000)) std::printf(" refit span: life %zu\n", life);
}
// Key-track is the second member of that class, and it is a PITCH-RATIO scalar: a sounding note
// must not be retuned by it, the next note-on must take it. Measured at a note away from the root
// (the ratio is 1.0 at the root whatever key-track says, so the root would prove nothing).
static void testAKeyTrackChangeSparesTheSoundingNoteAndReachesTheNextOne() {
SampleData still = rampForReadRate();
SampleData moved = rampForReadRate();
LiveParams blockA, blockB;
LiveValues halfTrack = foldLive(moved.play, moved.keyTrack);
halfTrack.keyTrack = 0.5; // half key-tracking: an octave up reads at ratio ~1.414, not 2.0
const std::vector<AudioSample> baseline =
renderPreserveCapable(still, blockA, nullptr, -1, 72);
const std::vector<AudioSample> swept =
renderPreserveCapable(moved, blockB, &halfTrack, 8, 72);
CHECK(baseline.size() == swept.size());
bool untouched = true;
for (std::size_t i = 0; i < baseline.size() && i < swept.size(); ++i) {
if (baseline[i] != swept[i]) { untouched = false; break; }
}
CHECK(untouched);
// The next note-on takes it, read straight off the ramp: under Varispeed the output value at
// frame i IS the read position, so the slope over one block is the pitch ratio.
auto slopePerFrame = [&](double keyTrack) {
SampleData fresh = rampForReadRate();
LiveParams block;
fresh.live = &block;
LiveValues published = foldLive(fresh.play, fresh.keyTrack);
published.keyTrack = keyTrack;
block.publish(published);
VoiceEngine engine(1, fresh);
engine.noteOn(72, 100);
std::vector<AudioSample> out;
engine.render(out, 512);
return (static_cast<double>(out.back()) - static_cast<double>(out.front())) /
static_cast<double>(out.size() - 1) * 200000.0;
};
// kKeyTrackDefault is 1.0 — full tracking, so an octave up reads at 2.0.
CHECK(std::fabs(slopePerFrame(1.0) - 2.0) < 0.01);
CHECK(std::fabs(slopePerFrame(0.5) - std::pow(2.0, 0.5)) < 0.01);
// And the value really is carried by the BLOCK: sample.play/keyTrack never moved.
CHECK(std::fabs(slopePerFrame(0.0) - 1.0) < 0.01);
}
// Trigger length is the third: it resolves playEnd_, so it re-spans the NEXT note and leaves the
// sounding one at the span it was struck with.
static void testATriggerLengthChangeSparesTheSoundingNoteAndReachesTheNextOne() {
auto triggerSource = [] {
SampleData s = rampForReadRate();
s.play.playMode = PlayMode::Trigger;
s.play.trigger.lengthFraction = 1.0;
s.play.trigAhd.holdFraction = 1.0; // flat through the span, so the span IS the lifetime
return s;
};
// The note's LIFETIME is what the fraction spans, so blocks-alive measures it directly.
auto blocksAlive = [&](double fraction) {
SampleData fresh = triggerSource();
LiveParams block;
fresh.live = &block;
LiveValues published = foldLive(fresh.play, fresh.keyTrack);
published.lengthFraction = fraction;
block.publish(published);
VoiceEngine engine(1, fresh);
engine.noteOn(60, 100);
std::vector<AudioSample> out;
int blocks = 0;
while (engine.activeVoiceCount() > 0 && blocks < 4000) {
engine.render(out, 512);
++blocks;
}
return blocks;
};
const int whole = blocksAlive(1.0);
const int quarterSpan = blocksAlive(0.25);
CHECK(whole > 100 && whole < 4000);
CHECK(std::fabs(static_cast<double>(quarterSpan) - 0.25 * whole) < 0.05 * whole);
// And the sounding note is spared. The published fraction is small enough that its span ENDS
// inside the window rendered — asserted, not assumed, because a fraction whose playEnd_ still
// sat past the render would leave the two runs identical whether the field were live or not.
constexpr int kSweepBlocks = 24; // renderPreserveCapable's own loop count
CHECK(blocksAlive(0.05) < kSweepBlocks);
SampleData still = triggerSource();
SampleData moved = triggerSource();
LiveParams blockA, blockB;
LiveValues shortened = foldLive(moved.play, moved.keyTrack);
shortened.lengthFraction = 0.05;
const std::vector<AudioSample> baseline =
renderPreserveCapable(still, blockA, nullptr, -1, 60);
const std::vector<AudioSample> swept =
renderPreserveCapable(moved, blockB, &shortened, 8, 60);
CHECK(baseline.size() == swept.size());
bool untouched = true;
for (std::size_t i = 0; i < baseline.size() && i < swept.size(); ++i) {
if (baseline[i] != swept[i]) { untouched = false; break; }
}
CHECK(untouched);
}
// --- What stays latched at note-on -------------------------------------------------------
static void testPitchRatioAndVelocityGainStayLatched() {
@@ -966,8 +1066,12 @@ static void testPitchRatioAndVelocityGainStayLatched() {
std::vector<AudioSample> out;
LiveValues hostile = foldLive(s.play, s.keyTrack);
// Everything the block CAN carry, moved as far as it goes. None of it names velocity, the
// note, the pitch ratio, or the PCM — that is the property under test.
// Everything the block CAN carry, moved as far as it goes. keyTrack and lengthFraction DO
// name the pitch ratio and the play span — they are here precisely because a SOUNDING voice
// must not read either, which is what makes them note-on-latched rather than live; the tests
// above are what prove the next note does take them.
hostile.keyTrack = 0.0;
hostile.lengthFraction = 0.05;
hostile.filterKeyTrack = 2.0;
hostile.filterSettings.cutoffNorm = 0.0f;
hostile.filterModAmount = 1.0;
@@ -1078,6 +1182,8 @@ int main() {
testEveryLiveFilterControlMovesTheSoundingNote();
testOneBlockServesTwoIndependentObservers();
testARateChangeSpareTheSoundingNoteAndReachesTheNextOne();
testAKeyTrackChangeSparesTheSoundingNoteAndReachesTheNextOne();
testATriggerLengthChangeSparesTheSoundingNoteAndReachesTheNextOne();
testAPitchOffsetChangeMovesTheSoundingNoteInBothEngines();
testAPublishedPitchOffsetLeavesTheStagedAttackWallClock();
testAPublishedPitchOffsetRefitsThePitchEnvelopeSpan();