PITCH/RATE deck: Rate and Pitch knobs compounded into one read increment, on a three-state commit predicate and payload v16

This commit is contained in:
2026-08-02 05:34:33 -04:00
parent ef59265e7a
commit 248f2f3842
32 changed files with 1098 additions and 163 deletions
+132
View File
@@ -720,6 +720,136 @@ static void testOneBlockServesTwoIndependentObservers() {
CHECK(seen.filterSettings.cutoffNorm == 0.2f);
}
// --- The third commit class: published live, read only at note-on -------------------------
// A ramp source read under Varispeed, so every output frame IS the read position — a moved read
// increment shows up directly rather than as a timbre change. The claim has two halves and both
// are asserted: the sounding note is byte-identical to one that never saw the publish, AND the
// next note-on takes the new rate. Asserting only the first would pass on a rate that never
// arrived at all.
static SampleData rampForReadRate() {
SampleData s;
s.frames.resize(200000);
for (std::size_t i = 0; i < s.frames.size(); ++i) {
s.frames[i] = static_cast<float>(static_cast<double>(i) / 200000.0);
}
s.sampleRate = kRate;
s.rootNote = 60;
s.play.adsr.sustainLevel = 1.0;
return s;
}
// A one-voice engine with its Preserve shifters actually SIZED, unlike renderWithLive's — the
// shared harness leaves them unconfigured, which silently routes a Preserve voice down the
// varispeed read and would make "in both engines" mean one engine twice.
static std::vector<AudioSample> renderPreserveCapable(SampleData& s, LiveParams& block,
const LiveValues* changed, int changeAfter,
int note) {
s.live = &block;
block.publish(foldLive(s.play));
VoiceEngine engine(1, s, /*preserveVoiceCap=*/0, /*preserveWindowFrames=*/2048);
engine.noteOn(note, 100);
std::vector<AudioSample> out;
for (int b = 0; b < 24; ++b) {
if (changed && b == changeAfter) block.publish(*changed);
engine.render(out, 512);
}
return out;
}
static void testARateChangeSpareTheSoundingNoteAndReachesTheNextOne() {
for (PitchEngine eng : {PitchEngine::Varispeed, PitchEngine::Preserve}) {
SampleData still = rampForReadRate();
SampleData moved = rampForReadRate();
still.play.pitchEngine = eng;
moved.play.pitchEngine = eng;
LiveParams blockA, blockB;
LiveValues halfRate = foldLive(moved.play);
halfRate.playRate = 0.5;
// At the ROOT note, so Preserve's shifter runs at shift 1.0 and never splices — the
// output is then the source at the read head under both engines, which is what makes
// the ramp readable as a read rate at all.
const std::vector<AudioSample> baseline =
renderPreserveCapable(still, blockA, nullptr, -1, 60);
const std::vector<AudioSample> swept =
renderPreserveCapable(moved, blockB, &halfRate, 8, 60);
// BYTE-identical, not merely close: the sounding voice never reads the field.
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 — measured as the note's LIFETIME, which is what Rate
// controls in both engines. (The ramp's instantaneous value is a read-position probe
// under Varispeed only: under Preserve the shifter's tap sits behind the feed and
// relocates at every splice, so the value at a given output frame is not the source
// there.) The rate is carried ONLY by the published block — sample.play keeps unity —
// so a lifetime that doubles can only have come from the block.
auto blocksAlive = [&](double rate) {
SampleData fresh = rampForReadRate();
fresh.play.pitchEngine = eng;
LiveParams block;
fresh.live = &block;
LiveValues published = foldLive(fresh.play);
published.playRate = rate;
block.publish(published);
VoiceEngine engine(1, fresh, /*preserveVoiceCap=*/0, /*preserveWindowFrames=*/2048);
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 atUnity = blocksAlive(1.0);
const int atHalf = blocksAlive(0.5);
CHECK(atUnity > 100 && atUnity < 4000); // the note really did run to its own end
CHECK(std::fabs(static_cast<double>(atHalf) - 2.0 * atUnity) < 0.05 * atUnity);
}
}
// Pitch is the other side of the same coin: it DOES move the note already sounding, under both
// engines — one more factor of the read increment under Varispeed, an addend to the shift under
// Preserve. Measured as a tail that departs from the untouched render while the frames before
// the publish stay byte-identical.
static void testAPitchOffsetChangeMovesTheSoundingNoteInBothEngines() {
for (PitchEngine eng : {PitchEngine::Varispeed, PitchEngine::Preserve}) {
SampleData still = periodicSine(200000, 64.0);
SampleData moved = periodicSine(200000, 64.0);
still.play.pitchEngine = eng;
moved.play.pitchEngine = eng;
LiveParams blockA, blockB;
LiveValues target = foldLive(moved.play);
target.pitchOffsetSemitones = -12.0;
const std::vector<AudioSample> baseline =
renderPreserveCapable(still, blockA, nullptr, -1, kTestNote);
const std::vector<AudioSample> swept =
renderPreserveCapable(moved, blockB, &target, 8, kTestNote);
double tailDiff = 0.0;
for (std::size_t i = 512 * 12; i < baseline.size(); ++i) {
tailDiff += std::fabs(static_cast<double>(swept[i]) -
static_cast<double>(baseline[i]));
}
CHECK(tailDiff > 1.0);
bool preChangeIdentical = true;
for (std::size_t i = 0; i < 512 * 8; ++i) {
if (swept[i] != baseline[i]) { preChangeIdentical = false; break; }
}
CHECK(preChangeIdentical);
}
}
// --- What stays latched at note-on -------------------------------------------------------
static void testPitchRatioAndVelocityGainStayLatched() {
@@ -859,6 +989,8 @@ int main() {
testEveryEnvelopeStageTimeAndLevelMovesTheSoundingNote();
testEveryLiveFilterControlMovesTheSoundingNote();
testOneBlockServesTwoIndependentObservers();
testARateChangeSpareTheSoundingNoteAndReachesTheNextOne();
testAPitchOffsetChangeMovesTheSoundingNoteInBothEngines();
testPitchRatioAndVelocityGainStayLatched();
testVelocityGainSurvivesAHostilePublishThatReallyLands();
if (g_fail == 0) std::printf("live_delivery tests passed\n");