Files
reasampler/tests/test_param_merge.cpp

186 lines
8.2 KiB
C++

// 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 <cstdio>
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<std::size_t>(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<std::size_t>(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;
}