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:
@@ -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.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 = █
|
||||
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");
|
||||
|
||||
Reference in New Issue
Block a user