// Standalone tests for LIVE PARAMETER DELIVERY into a sounding voice — no VST3, no REAPER, no // framework. The block's own publication contract is live_params_tests; this file asserts what // reaches the audio: the mid-stage rule holds normalized position, a level move glides, a // fresh note takes the newest block outright, every stage time and stage level on all three // envelopes moves the note already sounding, a filter knob does too, two snapshots sharing one // block behave identically (the drain slot), what stays latched at note-on stays latched, and // an unmoved block renders byte-identically to the engine with no block at all. #include "../src/core/instrument/engine/voice_engine.h" #include #include #include #include using namespace reasampler; using instrument::engine::LiveParams; using instrument::engine::LiveValues; using instrument::engine::foldLive; namespace flt = reasampler::instrument::engine::filter; static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) constexpr double kPi = 3.14159265358979323846; constexpr int kRate = 48000; static SampleData periodicSine(std::size_t frames, double period) { SampleData s; s.frames.resize(frames); for (std::size_t i = 0; i < frames; ++i) { s.frames[i] = static_cast(std::sin(2.0 * kPi * static_cast(i) / period)); } s.sampleRate = kRate; s.rootNote = 60; // Amp held wide open so a rendered frame is the (filtered) source, undisturbed by the // envelope under test elsewhere in this file. s.play.adsr.sustainLevel = 1.0; return s; } static double maxAbsDelta(const std::vector& v, std::size_t from, std::size_t to) { double worst = 0.0; for (std::size_t i = from + 1; i < to && i < v.size(); ++i) { const double d = std::fabs(static_cast(v[i]) - static_cast(v[i - 1])); if (d > worst) worst = d; } return worst; } static double peakOf(const std::vector& v, std::size_t from, std::size_t to) { double peak = 0.0; for (std::size_t i = from; i < to && i < v.size(); ++i) { peak = (std::max)(peak, std::fabs(static_cast(v[i]))); } return peak; } static SampleData filteredSine() { SampleData s = periodicSine(200000, 64.0); s.play.filter.enabled = true; s.play.filter.settings.cutoffNorm = 0.8f; s.play.filter.settings.resonanceNorm = 0.9f; s.play.filter.settings.morphNorm = 1.0f; return s; } // A low corner with real envelope depth, so the filter ENVELOPE's shape is what the timbre // depends on rather than the static knob position. static void filterSweep(SampleData& s) { s.play.filter.enabled = true; s.play.filter.settings.cutoffNorm = 0.15f; s.play.filter.settings.resonanceNorm = 0.6f; s.play.filter.settings.morphNorm = 1.0f; s.play.filter.modAmount = 0.8; } // Renders `blocks` blocks of `blockFrames` through a one-voice engine over `sample`, // republishing `changed` at the top of block `changeAfter` and gating the note off at the top // of `noteOffBlock` (-1 holds it). Voice-major render order is the engine's, so a fixed block // size is what makes two runs comparable. struct Run { std::vector out; }; // The note is an octave above the root on purpose: key-tracking scales (note - root), so a // root-note test would leave the key-track control with nothing to move. constexpr int kTestNote = 72; static Run renderWithLive(SampleData& sample, LiveParams* block, int blockFrames, int blocks, int changeAfter, const LiveValues* changed, int noteOffBlock = -1, int velocity = 100) { sample.live = block; if (block) block->publish(foldLive(sample.play)); VoiceEngine engine(1, sample); engine.noteOn(kTestNote, velocity); Run r; for (int b = 0; b < blocks; ++b) { if (block && changed && b == changeAfter) block->publish(*changed); if (b == noteOffBlock) engine.noteOff(kTestNote); engine.render(r.out, static_cast(blockFrames)); } return r; } // --- The mid-stage rule (candidate iv): hold normalized stage position ------------------ static void testStageDurationChangeHoldsPhase() { AdsrParams p; p.attackFrames = 1000; p.sustainLevel = 1.0; AdsrEnvelope unedited, edited; unedited.configure(p); edited.configure(p); unedited.noteOn(); edited.noteOn(); for (int i = 0; i < 500; ++i) { unedited.tick(); edited.tick(); } AdsrParams longer = p; longer.attackFrames = 2000; // doubled while the voice sits halfway up the attack edited.applyLive(longer); // Continuity: the very next frame is UNCHANGED by the edit. Exact, not approximate — // phi is held, and the level is a pure function of phi. const double a = unedited.tick(); const double b = edited.tick(); CHECK(a == b); CHECK(std::fabs(b - 0.5) < 1e-12); // and it is genuinely mid-attack, not a degenerate 0/1 // The remainder takes its share of the NEW duration: half of 2000 frames left to run. for (int i = 0; i < 998; ++i) edited.tick(); CHECK(edited.stage() == AdsrEnvelope::Stage::Attack); edited.tick(); CHECK(edited.stage() != AdsrEnvelope::Stage::Attack); } static void testShortenedStageStillLandsContinuously() { AdsrParams p; p.attackFrames = 1000; p.sustainLevel = 1.0; AdsrEnvelope env; env.configure(p); env.noteOn(); double last = 0.0; for (int i = 0; i < 800; ++i) last = env.tick(); AdsrParams shorter = p; shorter.attackFrames = 100; // now SHORTER than the frames already elapsed env.applyLive(shorter); const double next = env.tick(); // Recomputing from absolute elapsed (800/100) would clamp to 1.0 — a step from ~0.8. The // phi rule keeps the level where it was and finishes the remaining 20% over 20 frames. CHECK(std::fabs(next - last) < 2e-3); for (int i = 0; i < 19; ++i) env.tick(); CHECK(env.stage() != AdsrEnvelope::Stage::Attack); } static void testSustainLevelChangeGlides() { AdsrParams p; p.sustainLevel = 1.0; p.releaseFrames = 100000; AdsrEnvelope env; env.configure(p); env.noteOn(); for (int i = 0; i < 50; ++i) env.tick(); CHECK(env.stage() == AdsrEnvelope::Stage::Sustain); AdsrParams quieter = p; quieter.sustainLevel = 0.2; env.applyLive(quieter); double prev = 1.0; double worstStep = 0.0; double v = 0.0; for (int i = 0; i < 600; ++i) { v = env.tick(); // The first frame reproduces the pre-change level. Bounded rather than compared // exactly: 0.2 + fl(1.0 - 0.2) does round to exactly 1.0 for THESE operands, but the // property under test is continuity, not a bit-exactness the smoother never promised. if (i == 0) CHECK(std::fabs(v - 1.0) < 1e-15); const double step = std::fabs(v - prev); if (step > worstStep) worstStep = step; prev = v; } // A raw parameter swap would step 0.8 in one frame; the glide's largest single step is a // small fraction of that, and it terminates exactly on the new level. CHECK(worstStep < 0.05); CHECK(v == 0.2); } static void testPitchEnvelopeHoldsPhaseAndGlidesDepth() { // No hold stage, so the shape is the attack-decay one the pre-AHD envelope had. PitchEnvParams p; p.enabled = true; p.peakSemitones = 12.0; p.shape.attackFrames = 0; p.shape.decayFrames = 1000; p.shape.holdFraction = 0.0; PitchEnvelope a, b; a.configure(100000, p); b.configure(100000, p); a.noteOn(); b.noteOn(); for (int i = 0; i < 400; ++i) { a.tick(); b.tick(); } PitchEnvParams longer = p; longer.shape.decayFrames = 2000; b.applyLive(longer); // decay doubled mid-decay 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 // the edit is exactly what the unedited peer emits. PitchEnvelope c, d; c.configure(100000, p); d.configure(100000, p); c.noteOn(); d.noteOn(); 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 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(); CHECK(c.tick() == 0.0); } // The pitch envelope's new middle stage, on the same phi rule: a hold dialled mid-hold keeps // the level (flat by definition) and moves the boundary, and the fraction is taken against // what attack and decay left rather than against the whole span. static void testPitchEnvelopeHoldStagePlaysAndHoldsPhase() { PitchEnvParams p; p.enabled = true; p.peakSemitones = 12.0; p.shape.attackFrames = 100; p.shape.decayFrames = 100; p.shape.holdFraction = 0.5; // half of (1000 - 200) = 400 frames of hold PitchEnvelope e; e.configure(1000, p); e.noteOn(); for (int i = 0; i < 100; ++i) e.tick(); // through the attack CHECK(e.tick() == 12.0); // frame 100: at the peak, holding for (int i = 0; i < 398; ++i) e.tick(); // to the last frame of the hold CHECK(e.tick() == 12.0); // frame 499: still holding CHECK(e.tick() == 12.0); // frame 500: decay's own first frame CHECK(std::fabs(e.tick() - 12.0 * (1.0 - 1.0 / 100.0)) < 1e-12); // frame 501: descending // A live hold change mid-hold is continuous (the stage is flat) and the envelope still // finishes inside the span. PitchEnvelope f; f.configure(1000, p); f.noteOn(); for (int i = 0; i < 300; ++i) f.tick(); PitchEnvParams wider = p; wider.shape.holdFraction = 1.0; f.applyLive(wider); CHECK(f.tick() == 12.0); for (int i = 0; i < 1200; ++i) f.tick(); CHECK(f.tick() == 0.0); } // --- The fresh-note path: snap, never the phi rule --------------------------------------- static void testAFreshEnvelopeTakesANewlyDialledStageTimeOutright() { // Regression, both directions. The snap path once ran applyLive's phi rule, which reads a // stale duration of 0 as "this stage is already complete" and threw the newly-dialled // attack away for every note until the next reload. AdsrParams stale; // the AdsrParams default: every stage zero stale.sustainLevel = 1.0; AdsrEnvelope env; env.configure(stale); env.noteOn(); AdsrParams dialled = stale; dialled.attackFrames = 100; env.snapLive(dialled); CHECK(env.tick() == 0.0); // frame 0 of a 100-frame attack, not an instant 1.0 for (int i = 0; i < 49; ++i) env.tick(); CHECK(std::fabs(env.tick() - 0.5) < 1e-12); // Reverse: a stale non-zero attack against a newly-dialled ZERO one must not absorb a // full-scale step into a voice that has emitted nothing — that fades in a note the user // asked to be instant. AdsrParams staleLong; staleLong.attackFrames = 1000; staleLong.sustainLevel = 1.0; AdsrEnvelope instant; instant.configure(staleLong); instant.noteOn(); AdsrParams zeroAttack = staleLong; zeroAttack.attackFrames = 0; instant.snapLive(zeroAttack); CHECK(instant.tick() == 1.0); } static void testAFreshPitchEnvelopeTakesTheNewTimesOutright() { PitchEnvParams stale; // enabled, but every leg zero stale.enabled = true; PitchEnvelope env; env.configure(100000, stale); env.noteOn(); PitchEnvParams dialled = stale; dialled.peakSemitones = 12.0; dialled.shape.decayFrames = 1000; env.snapLive(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); } static void testANoteStartedAfterAPublishSoundsThePublishedEnvelope() { // End-to-end shape of the snap path: a live commit deliberately leaves the snapshot's own // sample.play stale, so the ONLY thing standing between a new note and a stale envelope is // the snap. This is the coverage whose absence let the phi-on-snap bug through. SampleData s = periodicSine(200000, 64.0); // adsr default: attack 0, sustain 1.0 LiveParams block; s.live = █ LiveValues dialled = foldLive(s.play); dialled.adsr.attackFrames = 24000; // half a second of attack, dialled before the note block.publish(dialled); VoiceEngine engine(1, s); engine.noteOn(kTestNote, 100); std::vector out; engine.render(out, 512); // Control: the same stale snapshot with no block at all speaks at full level immediately. SampleData bare = periodicSine(200000, 64.0); VoiceEngine bareEngine(1, bare); bareEngine.noteOn(kTestNote, 100); std::vector bareOut; bareEngine.render(bareOut, 512); const double barePeak = peakOf(bareOut, 0, bareOut.size()); const double peak = peakOf(out, 0, out.size()); CHECK(barePeak > 0.9); CHECK(peak < barePeak * 0.1); // 512 frames into a 24000-frame attack: ~2% of full scale // Reverse: a stale LONG attack against a published zero one. The note must speak at full // level within its first cycle rather than fading in over the smoother's decay. SampleData slow = periodicSine(200000, 64.0); slow.play.adsr.attackFrames = 24000; LiveParams block2; slow.live = &block2; LiveValues snappy = foldLive(slow.play); snappy.adsr.attackFrames = 0; block2.publish(snappy); VoiceEngine fast(1, slow); fast.noteOn(kTestNote, 100); std::vector fastOut; fast.render(fastOut, 512); // Source period 64 read at ratio 2 peaks at output frame 8; a spurious smoother fade-in // would still be at ~0.34 there. CHECK(peakOf(fastOut, 0, 32) > 0.9); } // --- Every envelope stage, end to end through the engine --------------------------------- // Renders the same note twice — once untouched, once with `mutate` published mid-note — and // asserts the field reached the SOUNDING voice (the tail diverges) and only after its publish. static void assertLiveFieldMovesTheSoundingNote(const char* name, void (*rig)(SampleData&), void (*mutate)(LiveValues&), int noteOffBlock) { SampleData still = periodicSine(200000, 64.0); SampleData moved = periodicSine(200000, 64.0); rig(still); rig(moved); LiveParams blockA, blockB; LiveValues target = foldLive(moved.play); mutate(target); const Run baseline = renderWithLive(still, &blockA, 512, 24, -1, nullptr, noteOffBlock); const Run edited = renderWithLive(moved, &blockB, 512, 24, 8, &target, noteOffBlock); CHECK(baseline.out.size() == edited.out.size()); double tailDiff = 0.0; for (std::size_t i = 512 * 9; i < baseline.out.size() && i < edited.out.size(); ++i) { tailDiff += std::fabs(static_cast(edited.out[i]) - static_cast(baseline.out[i])); } if (!(tailDiff > 1.0)) std::printf(" (never reached the voice: %s)\n", name); CHECK(tailDiff > 1.0); bool preChangeIdentical = true; for (std::size_t i = 0; i < 512 * 8 && i < baseline.out.size(); ++i) { if (edited.out[i] != baseline.out[i]) { preChangeIdentical = false; break; } } if (!preChangeIdentical) std::printf(" (moved before its publish: %s)\n", name); CHECK(preChangeIdentical); } static void testEveryEnvelopeStageTimeAndLevelMovesTheSoundingNote() { // Each rig puts the voice INSIDE the stage under test at the publish (block 8, output // frame 4096) — a stage already passed cannot move, which is the physics, not a gap. struct Case { const char* name; void (*rig)(SampleData&); void (*mutate)(LiveValues&); int noteOffBlock; }; const Case cases[] = { {"amp attack", [](SampleData& s) { s.play.adsr.attackFrames = 48000; }, [](LiveValues& v) { v.adsr.attackFrames = 4000; }, -1}, {"amp hold", [](SampleData& s) { s.play.adsr.holdFrames = 48000; s.play.adsr.decayFrames = 4000; s.play.adsr.sustainLevel = 0.1; }, [](LiveValues& v) { v.adsr.holdFrames = 5000; }, -1}, {"amp decay", [](SampleData& s) { s.play.adsr.decayFrames = 48000; s.play.adsr.sustainLevel = 0.0; }, [](LiveValues& v) { v.adsr.decayFrames = 8000; }, -1}, {"amp sustain", [](SampleData& s) { s.play.adsr.sustainLevel = 1.0; }, [](LiveValues& v) { v.adsr.sustainLevel = 0.2; }, -1}, {"amp release", [](SampleData& s) { s.play.adsr.releaseFrames = 48000; }, [](LiveValues& v) { v.adsr.releaseFrames = 6000; }, 2}, // The filter envelope: swept over a low corner with real depth, so its shape is the // only thing the timbre depends on. The amp release is long so a gated-off voice // keeps sounding while the filter release is measured. {"filter env attack", [](SampleData& s) { filterSweep(s); s.play.filter.env.attackFrames = 48000; }, [](LiveValues& v) { v.filterEnv.attackFrames = 4000; }, -1}, {"filter env hold", [](SampleData& s) { filterSweep(s); s.play.filter.env.holdFrames = 48000; s.play.filter.env.decayFrames = 4000; s.play.filter.env.sustainLevel = 0.0; }, [](LiveValues& v) { v.filterEnv.holdFrames = 5000; }, -1}, {"filter env decay", [](SampleData& s) { filterSweep(s); s.play.filter.env.decayFrames = 48000; s.play.filter.env.sustainLevel = 0.0; }, [](LiveValues& v) { v.filterEnv.decayFrames = 8000; }, -1}, {"filter env sustain", [](SampleData& s) { filterSweep(s); }, [](LiveValues& v) { v.filterEnv.sustainLevel = 0.0; }, -1}, {"filter env release", [](SampleData& s) { filterSweep(s); s.play.filter.env.releaseFrames = 48000; s.play.adsr.releaseFrames = 480000; }, [](LiveValues& v) { v.filterEnv.releaseFrames = 6000; }, 2}, {"pitch env attack", [](SampleData& s) { s.play.pitchEnv.enabled = true; s.play.pitchEnv.shape.attackFrames = 48000; s.play.pitchEnv.shape.decayFrames = 48000; s.play.pitchEnv.peakSemitones = 12.0; }, [](LiveValues& v) { v.pitchEnv.shape.attackFrames = 4000; }, -1}, {"pitch env decay", [](SampleData& s) { s.play.pitchEnv.enabled = true; s.play.pitchEnv.shape.decayFrames = 48000; s.play.pitchEnv.peakSemitones = 12.0; }, [](LiveValues& v) { v.pitchEnv.shape.decayFrames = 8000; }, -1}, {"pitch env depth", [](SampleData& s) { s.play.pitchEnv.enabled = true; s.play.pitchEnv.shape.decayFrames = 480000; s.play.pitchEnv.peakSemitones = 12.0; }, [](LiveValues& v) { v.pitchEnv.peakSemitones = 0.0; }, -1}, }; for (const Case& c : cases) { assertLiveFieldMovesTheSoundingNote(c.name, c.rig, c.mutate, c.noteOffBlock); } } // --- The filter DSP's glide property, finally exercised --------------------------------- static void testCutoffMoveAcrossPrepareDoesNotStep() { flt::FilterSettings s; s.cutoffNorm = 0.8f; s.resonanceNorm = 1.0f; // maximum Q: the worst case for a coefficient step s.morphNorm = 1.0f; flt::VoiceFilter glide, cut; glide.prepare(s, kRate); cut.prepare(s, kRate); std::vector a, b; const int boundary = 2000; for (int i = 0; i < 4000; ++i) { if (i == boundary) { flt::FilterSettings moved = s; moved.cutoffNorm = 0.3f; glide.prepare(moved, kRate); // state PRESERVED — the documented glide property cut.prepare(moved, kRate); cut.reset(); // the control: state cleared, as at note-on } const float x = static_cast(std::sin(2.0 * kPi * static_cast(i) / 48.0)); a.push_back(glide.process(0, x)); b.push_back(cut.process(0, x)); } const double localMax = maxAbsDelta(a, boundary - 400, boundary - 1); const double glideStep = std::fabs(static_cast(a[boundary]) - static_cast(a[boundary - 1])); const double cutStep = std::fabs(static_cast(b[boundary]) - static_cast(b[boundary - 1])); // Preserving state keeps the boundary frame inside the signal's own frame-to-frame range; // clearing it does not — which is what proves this assertion discriminates rather than // passing on any pair of numbers. CHECK(glideStep <= localMax); CHECK(cutStep > glideStep * 4.0); } // --- Delivery into a sounding voice ------------------------------------------------------ static void testUnmovedBlockIsByteIdenticalToNoBlockAtAll() { SampleData bare = filteredSine(); SampleData blocked = filteredSine(); LiveParams block; const Run without = renderWithLive(bare, nullptr, 512, 20, -1, nullptr); const Run with = renderWithLive(blocked, &block, 512, 20, -1, nullptr); CHECK(without.out.size() == with.out.size()); bool identical = true; for (std::size_t i = 0; i < without.out.size() && i < with.out.size(); ++i) { if (without.out[i] != with.out[i]) { identical = false; break; } } // Also the migration bar: a blob saved before this change folds to exactly the values the // build already resolved, so reopening it sounds identical rather than merely close. CHECK(identical); } static void testEveryLiveFilterControlMovesTheSoundingNote() { struct Case { const char* name; void (*mutate)(LiveValues&); }; const Case cases[] = { {"cutoff", [](LiveValues& v) { v.filterSettings.cutoffNorm = 0.15f; }}, {"Q", [](LiveValues& v) { v.filterSettings.resonanceNorm = 0.1f; }}, {"morph", [](LiveValues& v) { v.filterSettings.morphNorm = 0.0f; }}, {"drive", [](LiveValues& v) { v.filterSettings.driveNorm = 1.0f; }}, {"mod", [](LiveValues& v) { v.filterModAmount = 1.0; }}, {"keytrack", [](LiveValues& v) { v.filterKeyTrack = 2.0; }}, }; for (const Case& c : cases) { SampleData still = filteredSine(); SampleData moved = filteredSine(); LiveParams blockA, blockB; LiveValues target = foldLive(moved.play); c.mutate(target); const Run baseline = renderWithLive(still, &blockA, 512, 24, -1, nullptr); const Run swept = renderWithLive(moved, &blockB, 512, 24, 8, &target); // It moved THIS note: the tail after the publish differs audibly from the untouched // render of the same note. double tailDiff = 0.0; for (std::size_t i = 512 * 12; i < baseline.out.size(); ++i) { tailDiff += std::fabs(static_cast(swept.out[i]) - static_cast(baseline.out[i])); } if (!(tailDiff > 1.0)) std::printf(" (control: %s)\n", c.name); CHECK(tailDiff > 1.0); // Nothing before the publish moved (the block is observed at block boundaries only). bool preChangeIdentical = true; for (std::size_t i = 0; i < 512 * 8; ++i) { if (swept.out[i] != baseline.out[i]) { preChangeIdentical = false; break; } } CHECK(preChangeIdentical); // And it ARRIVED as a glide, not as a step. Measured as how far the swept render has // departed from the untouched one in the first frames after the publish, against how // far it departs once settled: a glide has barely begun to diverge, a snapped delivery // is already all the way there. // // This is the assertion that discriminates. A single-frame-spike metric does NOT: the // TPT filter preserves state across prepare(), so even an instantaneous coefficient // jump produces no isolated output spike — measured, by defeating the ramp and // re-running, the spike statistic was unchanged while these two numbers converged. // // The window is a FRACTION OF THE GLIDE, not a frame count: kLiveRampSeconds is the // full travel time, so at 1/240 of it a working glide has barely started when the // window closes. kGlideMargin then puts the bound at the geometric middle of the two // MEASURED populations — with the ramp in place these six controls ratio 0.0005..0.060; // with it defeated (every live move delivered as a snap, run) they ratio 0.52..1.10. // The bound lands at 0.175: ~3x above the worst glide, ~3x below the tamest snap. const std::size_t rampFrames = static_cast(instrument::engine::kLiveRampSeconds * kRate); const std::size_t window = rampFrames / 240; const double kGlideMargin = 42.0; const double bound = kGlideMargin * static_cast(window) / static_cast(rampFrames); double immediate = 0.0; for (std::size_t i = 512 * 8; i < 512 * 8 + window; ++i) { immediate = (std::max)(immediate, std::fabs(static_cast(swept.out[i]) - static_cast(baseline.out[i]))); } double settled = 0.0; for (std::size_t i = 512 * 14; i < baseline.out.size(); ++i) { settled = (std::max)(settled, std::fabs(static_cast(swept.out[i]) - static_cast(baseline.out[i]))); } if (!(immediate <= settled * bound)) std::printf(" (glide: %s ratio %f vs bound %f)\n", c.name, settled > 0.0 ? immediate / settled : -1.0, bound); CHECK(immediate <= settled * bound); } } static void testOneBlockServesTwoIndependentObservers() { // Two snapshots, one block — exactly the processor's live_/draining_ shape. The claim is // narrow and specific: read() does NOT consume the generation, so the second engine to // observe a publish sees it as fully as the first. Two identically-built engines are // otherwise identical by construction, so that is the only thing the comparison pins. SampleData liveSnapshot = filteredSine(); SampleData drainSnapshot = filteredSine(); LiveParams block; liveSnapshot.live = █ drainSnapshot.live = █ block.publish(foldLive(liveSnapshot.play)); VoiceEngine liveEngine(1, liveSnapshot); VoiceEngine drainEngine(1, drainSnapshot); liveEngine.noteOn(60, 100); drainEngine.noteOn(60, 100); std::vector a, b; LiveValues moved = foldLive(liveSnapshot.play); moved.filterSettings.cutoffNorm = 0.2f; for (int blk = 0; blk < 24; ++blk) { if (blk == 8) block.publish(moved); liveEngine.render(a, 512); drainEngine.render(b, 512); } CHECK(a.size() == b.size()); bool same = true; for (std::size_t i = 0; i < a.size() && i < b.size(); ++i) { if (a[i] != b[i]) { same = false; break; } } CHECK(same); // The shared block genuinely moved the sound, so "identical" is a claim about both // observers having seen it rather than about nothing having happened. double moveEnergy = 0.0; for (std::size_t i = 512 * 12; i < a.size(); ++i) moveEnergy += std::fabs(a[i]); CHECK(moveEnergy > 1.0); // And a THIRD observer, after both engines have read it, still sees the same publish. LiveValues seen; CHECK(block.read(seen) != 0); CHECK(seen.filterSettings.cutoffNorm == 0.2f); } // --- What stays latched at note-on ------------------------------------------------------- static void testPitchRatioAndVelocityGainStayLatched() { // A ramp source read under Varispeed: every output frame is (source at readPos) * velocity // gain, so a moved pitch ratio or a moved velocity gain would show up directly. // // The filter and the pitch envelope are OFF here on purpose — that is what makes the read // rate provable arithmetic. It also means the block's filter and pitch-envelope fields // cannot land on this voice; that they DO land on a voice that has them enabled, and still // leave the velocity gain alone, is the next test's job. SampleData s; s.frames.resize(100000); for (std::size_t i = 0; i < s.frames.size(); ++i) { s.frames[i] = static_cast(static_cast(i) / 100000.0); } s.sampleRate = kRate; s.rootNote = 60; s.velocityCurve = VelocityCurve::linear(); s.play.adsr.sustainLevel = 1.0; LiveParams block; s.live = █ block.publish(foldLive(s.play)); VoiceEngine engine(1, s); engine.noteOn(72, 64); // an octave up: ratio 2.0 std::vector out; LiveValues hostile = foldLive(s.play); // 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. hostile.filterKeyTrack = 2.0; hostile.filterSettings.cutoffNorm = 0.0f; hostile.filterModAmount = 1.0; hostile.pitchEnv.shape.attackFrames = 4800; hostile.pitchEnv.shape.decayFrames = 4800; hostile.pitchEnv.peakSemitones = 24.0; hostile.adsr.attackFrames = 96000; // a timed stage the voice is already past for (int blk = 0; blk < 8; ++blk) { if (blk == 2) block.publish(hostile); engine.render(out, 512); } const double velocityGain = s.velocityCurve.eval(64.0); bool pitchAndGainHeld = true; for (std::size_t i = 0; i < out.size(); ++i) { const double expected = (static_cast(2 * i) / 100000.0) * velocityGain; // ratio 2.0, latched gain if (std::fabs(static_cast(out[i]) - expected) > 1e-6) { pitchAndGainHeld = false; break; } } CHECK(pitchAndGainHeld); // Positive control on the same rig: a field that IS live does change the output, so the // assertion above is not simply proving the block was ignored wholesale. SampleData s2 = s; LiveParams block2; s2.live = &block2; block2.publish(foldLive(s2.play)); VoiceEngine engine2(1, s2); engine2.noteOn(72, 64); std::vector out2; LiveValues quieter = foldLive(s2.play); quieter.adsr.sustainLevel = 0.25; for (int blk = 0; blk < 8; ++blk) { if (blk == 2) block2.publish(quieter); engine2.render(out2, 512); } CHECK(std::fabs(static_cast(out2.back()) - static_cast(out.back())) > 1e-4); } static void testVelocityGainSurvivesAHostilePublishThatReallyLands() { // Filter AND pitch envelope enabled, so every field the block carries actually reaches the // voice. velAmount is 0, so velocity enters the render exactly once — as the amp gain // latched at note-on — which makes two runs at different velocities exactly proportional // unless the publish moved that gain (a re-derived gain would have to preserve the ratio // 100:64 to slip through). SampleData rig = periodicSine(200000, 64.0); rig.velocityCurve = VelocityCurve::linear(); filterSweep(rig); rig.play.filter.velAmount = 0.0; rig.play.pitchEnv.enabled = true; rig.play.pitchEnv.shape.decayFrames = 24000; rig.play.pitchEnv.peakSemitones = 3.0; LiveValues hostile = foldLive(rig.play); hostile.filterKeyTrack = 2.0; hostile.filterSettings.cutoffNorm = 0.9f; hostile.filterModAmount = -1.0; hostile.filterEnv.decayFrames = 4800; hostile.filterEnv.sustainLevel = 0.0; hostile.pitchEnv.shape.attackFrames = 4800; hostile.pitchEnv.shape.decayFrames = 4800; hostile.pitchEnv.peakSemitones = 24.0; hostile.adsr.sustainLevel = 0.4; SampleData quiet = rig, loud = rig, untouched = rig; LiveParams blockQuiet, blockLoud, blockUntouched; const Run atQuiet = renderWithLive(quiet, &blockQuiet, 512, 16, 2, &hostile, -1, 64); const Run atLoud = renderWithLive(loud, &blockLoud, 512, 16, 2, &hostile, -1, 100); const Run noPublish = renderWithLive(untouched, &blockUntouched, 512, 16, -1, nullptr, -1, 64); // The publish is not inert: it moved the note it was published into. double landed = 0.0; for (std::size_t i = 512 * 3; i < atQuiet.out.size() && i < noPublish.out.size(); ++i) { landed += std::fabs(static_cast(atQuiet.out[i]) - static_cast(noPublish.out[i])); } CHECK(landed > 1.0); // ...and through all of it the two velocities differ by exactly the curve's ratio. const double ratio = rig.velocityCurve.eval(100.0) / rig.velocityCurve.eval(64.0); CHECK(ratio > 1.5); // the curve really does separate these two velocities bool proportional = true; for (std::size_t i = 0; i < atQuiet.out.size() && i < atLoud.out.size(); ++i) { if (std::fabs(static_cast(atLoud.out[i]) - static_cast(atQuiet.out[i]) * ratio) > 1e-6) { proportional = false; break; } } CHECK(proportional); } int main() { testStageDurationChangeHoldsPhase(); testShortenedStageStillLandsContinuously(); testSustainLevelChangeGlides(); testPitchEnvelopeHoldsPhaseAndGlidesDepth(); testPitchEnvelopeHoldStagePlaysAndHoldsPhase(); testAFreshEnvelopeTakesANewlyDialledStageTimeOutright(); testAFreshPitchEnvelopeTakesTheNewTimesOutright(); testCutoffMoveAcrossPrepareDoesNotStep(); testUnmovedBlockIsByteIdenticalToNoBlockAtAll(); testANoteStartedAfterAPublishSoundsThePublishedEnvelope(); testEveryEnvelopeStageTimeAndLevelMovesTheSoundingNote(); testEveryLiveFilterControlMovesTheSoundingNote(); testOneBlockServesTwoIndependentObservers(); testPitchRatioAndVelocityGainStayLatched(); testVelocityGainSurvivesAHostilePublishThatReallyLands(); if (g_fail == 0) std::printf("live_delivery tests passed\n"); return g_fail == 0 ? 0 : 1; }