// Standalone tests for the block-boundary merge — the AUTHORITY LIFETIME of a host automation // point. The load-bearing one is the RELEASE: a point outranks the model only until the model // carries it. Held forever, one point defeats every later state restore, bake reset and knob // move; released too eagerly, a lane in flight reverts for a block. #include "../src/core/instrument/param/param_merge.h" #include "../src/core/instrument/param/param_id.h" #include "../src/core/instrument/param/param_live.h" #include "../src/core/instrument/param/param_units.h" #include "../src/core/instrument/map/sample_map.h" #include "../src/core/instrument/ui/deck_values.h" #include using namespace reasampler; using namespace reasampler::instrument::param; using reasampler::instrument::engine::LiveValues; using reasampler::instrument::engine::foldLive; using reasampler::instrument::map::InstrumentParams; using reasampler::instrument::map::resolvePlay; using reasampler::instrument::ui::DeckParam; static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) #define CHECK_ID(cond, id) do { if(!(cond)) { \ std::printf("FAIL line %d (param %u): %s\n", __LINE__, (id), #cond); ++g_fail; } } while(0) namespace { constexpr int kRate = 48000; constexpr std::size_t kCutoff = static_cast(DeckParam::kFilterCutoff); LiveValues modelBlock(const InstrumentParams& params) { return foldLive(resolvePlay(params.play, kRate), params.keyTrack); } // A slot array with one lane driving `deck`. struct Slots { AutomationSlot s[kDeckParamSlots] = {}; AutomationSlot* operator()() { return s; } }; } // namespace // THE test this module exists for. A point lands, the merge applies it over the model; the model // is then rewritten to something else while the hold is STILL outstanding, and the point must win // — that is the ≤one-tick window the hold is for. Once the fold has caught the model up and the // slot is marked folded, the merge releases it and the MODEL wins, permanently. static void testAHeldPointOutranksTheModelOnlyUntilTheModelCarriesIt() { InstrumentParams automated; automated.play.filter.settings.cutoffNorm = 0.9f; // 1. The point is held: a model that says 0.9 loses to the lane's 0.2. Slots slots; slots.s[kCutoff] = AutomationSlot{0.2, /*held=*/true, /*folded=*/false}; LiveValues block = modelBlock(automated); mergeAutomation(block, slots(), kDeckParamSlots, kRate); CHECK(block.filterSettings.cutoffNorm == 0.2f); CHECK(slots.s[kCutoff].held); // still outstanding — nothing has folded it // 2. A state restore lands a different value while the hold is outstanding. Still the lane's: // this is the window the hold exists for, and it is the ONLY window. InstrumentParams restored; restored.play.filter.settings.cutoffNorm = 0.55f; block = modelBlock(restored); mergeAutomation(block, slots(), kDeckParamSlots, kRate); CHECK(block.filterSettings.cutoffNorm == 0.2f); // 3. The UI folds the point into the model and republishes; the merge sees the release. InstrumentParams folded; folded.play.filter.settings.cutoffNorm = 0.2f; slots.s[kCutoff].folded = true; block = modelBlock(folded); mergeAutomation(block, slots(), kDeckParamSlots, kRate); CHECK(block.filterSettings.cutoffNorm == 0.2f); CHECK(!slots.s[kCutoff].held); // RELEASED — this is the whole fix // 4. And now a later writer — a preset load, a bake reset, a knob — actually reaches the // audio. This is what a latch with no release makes impossible. block = modelBlock(restored); mergeAutomation(block, slots(), kDeckParamSlots, kRate); CHECK(block.filterSettings.cutoffNorm == 0.55f); } // The release must not leak across points: a lane that sent a NEW point after the fold read the // previous one is still driving, and its new value must survive the release of the old. static void testANewPointAfterTheFoldIsNotReleasedByIt() { Slots slots; // The audio thread re-holds at 0.7; the UI's fold was of the earlier 0.2, so the sequence // comparison the shell runs leaves `folded` false for this newer point. slots.s[kCutoff] = AutomationSlot{0.7, /*held=*/true, /*folded=*/false}; InstrumentParams foldedModel; foldedModel.play.filter.settings.cutoffNorm = 0.2f; LiveValues block = modelBlock(foldedModel); mergeAutomation(block, slots(), kDeckParamSlots, kRate); CHECK(block.filterSettings.cutoffNorm == 0.7f); CHECK(slots.s[kCutoff].held); } // A slot that never took a point leaves the block exactly as the model folded it. static void testAnUnheldSlotLeavesTheBlockAlone() { Slots slots; InstrumentParams p; p.play.adsr.attackSeconds = 0.25; const LiveValues expected = modelBlock(p); LiveValues block = modelBlock(p); mergeAutomation(block, slots(), kDeckParamSlots, kRate); CHECK(expected == block); } // The dirty gate at its source. A lane resending the value it already sent — the steady state of // a flat segment in read mode — must report that nothing moved, so the block is never re-read, // re-merged or republished. A lane that MOVED must report that it did, and so must a repeat that // arrives after the hold was released (some other writer may have moved the model since). static void testARepeatOfAStandingHoldMovesNothing() { AutomationSlot slot{0.4, /*held=*/true, /*folded=*/false}; CHECK(!automationPointMoves(slot, 0.4)); CHECK(automationPointMoves(slot, 0.41)); slot.held = false; CHECK(automationPointMoves(slot, 0.4)); } // The gate the merge publishes through. Two blocks folded from the same parameter set and merged // with the same slot state compare EQUAL — which a byte compare does not reliably report, since // LiveValues carries padding no copy is required to preserve. This is the assertion that fails if // operator== is ever "simplified" back into a memcmp. static void testTwoIdenticalMergesCompareEqual() { InstrumentParams p; p.play.adsr.attackSeconds = 0.13; p.play.trigger.lengthFraction = 0.6; Slots slots; slots.s[kCutoff] = AutomationSlot{0.4, /*held=*/true, /*folded=*/false}; LiveValues first = modelBlock(p); mergeAutomation(first, slots(), kDeckParamSlots, kRate); LiveValues again = modelBlock(p); mergeAutomation(again, slots(), kDeckParamSlots, kRate); CHECK(first == again); slots.s[kCutoff].norm = 0.41; LiveValues moved = modelBlock(p); mergeAutomation(moved, slots(), kDeckParamSlots, kRate); CHECK(first != moved); } // The merge addresses a slot by DeckParam ORDINAL, which is the one thing it does that // param_live's equivalence test cannot see: a slot recovered as the wrong enumerator would patch // a neighbouring control. Swept over every exposed control for that reason, not to re-assert the // value laws param_live already owns. static void testEverySlotResolvesToItsOwnControl() { const double kPositions[] = {0.0, 0.29, 0.5, 0.77, 1.0}; for (const ParamRow& row : exposedParams()) { if (row.deck == DeckParam::kMasterGain) continue; // reaches the audio beside the block for (double norm : kPositions) { InstrumentParams written; if (row.deck == DeckParam::kKeyTrack) { written.keyTrack = reasampler::instrument::ui::keyTrackFromNorm(norm); } else { writeHostParam(row.deck, written.play, norm); } const LiveValues expected = modelBlock(written); Slots slots; slots.s[static_cast(row.deck)] = AutomationSlot{norm, /*held=*/true, /*folded=*/false}; LiveValues merged = modelBlock(InstrumentParams{}); mergeAutomation(merged, slots(), kDeckParamSlots, kRate); CHECK_ID(expected == merged, row.id); } } } int main() { testAHeldPointOutranksTheModelOnlyUntilTheModelCarriesIt(); testANewPointAfterTheFoldIsNotReleasedByIt(); testAnUnheldSlotLeavesTheBlockAlone(); testARepeatOfAStandingHoldMovesNothing(); testTwoIdenticalMergesCompareEqual(); testEverySlotResolvesToItsOwnControl(); if (g_fail == 0) std::printf("param_merge: all tests passed\n"); return g_fail == 0 ? 0 : 1; }