Bound the automation hold to the window the model has not caught up on, and make that authority model stated, enforced and tested
This commit is contained in:
@@ -0,0 +1,185 @@
|
||||
// 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;
|
||||
}
|
||||
Reference in New Issue
Block a user