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:
@@ -938,6 +938,106 @@ static void testAPublishedPitchOffsetRefitsThePitchEnvelopeSpan() {
|
||||
if (!(life > 11000 && life < 13000)) std::printf(" refit span: life %zu\n", life);
|
||||
}
|
||||
|
||||
// Key-track is the second member of that class, and it is a PITCH-RATIO scalar: a sounding note
|
||||
// must not be retuned by it, the next note-on must take it. Measured at a note away from the root
|
||||
// (the ratio is 1.0 at the root whatever key-track says, so the root would prove nothing).
|
||||
static void testAKeyTrackChangeSparesTheSoundingNoteAndReachesTheNextOne() {
|
||||
SampleData still = rampForReadRate();
|
||||
SampleData moved = rampForReadRate();
|
||||
LiveParams blockA, blockB;
|
||||
LiveValues halfTrack = foldLive(moved.play, moved.keyTrack);
|
||||
halfTrack.keyTrack = 0.5; // half key-tracking: an octave up reads at ratio ~1.414, not 2.0
|
||||
|
||||
const std::vector<AudioSample> baseline =
|
||||
renderPreserveCapable(still, blockA, nullptr, -1, 72);
|
||||
const std::vector<AudioSample> swept =
|
||||
renderPreserveCapable(moved, blockB, &halfTrack, 8, 72);
|
||||
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, read straight off the ramp: under Varispeed the output value at
|
||||
// frame i IS the read position, so the slope over one block is the pitch ratio.
|
||||
auto slopePerFrame = [&](double keyTrack) {
|
||||
SampleData fresh = rampForReadRate();
|
||||
LiveParams block;
|
||||
fresh.live = █
|
||||
LiveValues published = foldLive(fresh.play, fresh.keyTrack);
|
||||
published.keyTrack = keyTrack;
|
||||
block.publish(published);
|
||||
VoiceEngine engine(1, fresh);
|
||||
engine.noteOn(72, 100);
|
||||
std::vector<AudioSample> out;
|
||||
engine.render(out, 512);
|
||||
return (static_cast<double>(out.back()) - static_cast<double>(out.front())) /
|
||||
static_cast<double>(out.size() - 1) * 200000.0;
|
||||
};
|
||||
// kKeyTrackDefault is 1.0 — full tracking, so an octave up reads at 2.0.
|
||||
CHECK(std::fabs(slopePerFrame(1.0) - 2.0) < 0.01);
|
||||
CHECK(std::fabs(slopePerFrame(0.5) - std::pow(2.0, 0.5)) < 0.01);
|
||||
// And the value really is carried by the BLOCK: sample.play/keyTrack never moved.
|
||||
CHECK(std::fabs(slopePerFrame(0.0) - 1.0) < 0.01);
|
||||
}
|
||||
|
||||
// Trigger length is the third: it resolves playEnd_, so it re-spans the NEXT note and leaves the
|
||||
// sounding one at the span it was struck with.
|
||||
static void testATriggerLengthChangeSparesTheSoundingNoteAndReachesTheNextOne() {
|
||||
auto triggerSource = [] {
|
||||
SampleData s = rampForReadRate();
|
||||
s.play.playMode = PlayMode::Trigger;
|
||||
s.play.trigger.lengthFraction = 1.0;
|
||||
s.play.trigAhd.holdFraction = 1.0; // flat through the span, so the span IS the lifetime
|
||||
return s;
|
||||
};
|
||||
// The note's LIFETIME is what the fraction spans, so blocks-alive measures it directly.
|
||||
auto blocksAlive = [&](double fraction) {
|
||||
SampleData fresh = triggerSource();
|
||||
LiveParams block;
|
||||
fresh.live = █
|
||||
LiveValues published = foldLive(fresh.play, fresh.keyTrack);
|
||||
published.lengthFraction = fraction;
|
||||
block.publish(published);
|
||||
VoiceEngine engine(1, fresh);
|
||||
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 whole = blocksAlive(1.0);
|
||||
const int quarterSpan = blocksAlive(0.25);
|
||||
CHECK(whole > 100 && whole < 4000);
|
||||
CHECK(std::fabs(static_cast<double>(quarterSpan) - 0.25 * whole) < 0.05 * whole);
|
||||
|
||||
// And the sounding note is spared. The published fraction is small enough that its span ENDS
|
||||
// inside the window rendered — asserted, not assumed, because a fraction whose playEnd_ still
|
||||
// sat past the render would leave the two runs identical whether the field were live or not.
|
||||
constexpr int kSweepBlocks = 24; // renderPreserveCapable's own loop count
|
||||
CHECK(blocksAlive(0.05) < kSweepBlocks);
|
||||
|
||||
SampleData still = triggerSource();
|
||||
SampleData moved = triggerSource();
|
||||
LiveParams blockA, blockB;
|
||||
LiveValues shortened = foldLive(moved.play, moved.keyTrack);
|
||||
shortened.lengthFraction = 0.05;
|
||||
const std::vector<AudioSample> baseline =
|
||||
renderPreserveCapable(still, blockA, nullptr, -1, 60);
|
||||
const std::vector<AudioSample> swept =
|
||||
renderPreserveCapable(moved, blockB, &shortened, 8, 60);
|
||||
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);
|
||||
}
|
||||
|
||||
// --- What stays latched at note-on -------------------------------------------------------
|
||||
|
||||
static void testPitchRatioAndVelocityGainStayLatched() {
|
||||
@@ -966,8 +1066,12 @@ static void testPitchRatioAndVelocityGainStayLatched() {
|
||||
|
||||
std::vector<AudioSample> out;
|
||||
LiveValues hostile = foldLive(s.play, s.keyTrack);
|
||||
// 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.
|
||||
// Everything the block CAN carry, moved as far as it goes. keyTrack and lengthFraction DO
|
||||
// name the pitch ratio and the play span — they are here precisely because a SOUNDING voice
|
||||
// must not read either, which is what makes them note-on-latched rather than live; the tests
|
||||
// above are what prove the next note does take them.
|
||||
hostile.keyTrack = 0.0;
|
||||
hostile.lengthFraction = 0.05;
|
||||
hostile.filterKeyTrack = 2.0;
|
||||
hostile.filterSettings.cutoffNorm = 0.0f;
|
||||
hostile.filterModAmount = 1.0;
|
||||
@@ -1078,6 +1182,8 @@ int main() {
|
||||
testEveryLiveFilterControlMovesTheSoundingNote();
|
||||
testOneBlockServesTwoIndependentObservers();
|
||||
testARateChangeSpareTheSoundingNoteAndReachesTheNextOne();
|
||||
testAKeyTrackChangeSparesTheSoundingNoteAndReachesTheNextOne();
|
||||
testATriggerLengthChangeSparesTheSoundingNoteAndReachesTheNextOne();
|
||||
testAPitchOffsetChangeMovesTheSoundingNoteInBothEngines();
|
||||
testAPublishedPitchOffsetLeavesTheStagedAttackWallClock();
|
||||
testAPublishedPitchOffsetRefitsThePitchEnvelopeSpan();
|
||||
|
||||
+51
-12
@@ -1,6 +1,7 @@
|
||||
// Standalone tests for the audio thread's parameter patch. The load-bearing one is the
|
||||
// EQUIVALENCE assertion: patching a control into the live block must produce, bit for bit, the
|
||||
// block the model path would have folded — which is what makes a second routing table safe.
|
||||
// Standalone tests for a host parameter write, both sides of the model/audio split. The
|
||||
// load-bearing one is the EQUIVALENCE assertion: patching a control into the live block must
|
||||
// produce exactly the block the model path would have folded after the same write — which is what
|
||||
// makes a second routing table safe.
|
||||
|
||||
#include "../src/core/instrument/param/param_live.h"
|
||||
|
||||
@@ -8,9 +9,10 @@
|
||||
#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 "../src/core/util/curve_law.h"
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
|
||||
|
||||
using namespace reasampler;
|
||||
using namespace reasampler::instrument::param;
|
||||
@@ -71,9 +73,13 @@ InstrumentParams dialledParams() {
|
||||
} // namespace
|
||||
|
||||
// THE assertion this module exists for. For every exposed control and several normalized
|
||||
// positions: writing it through the model and folding must equal patching it into the folded
|
||||
// block. Bytes, not fields — a member the patch forgot to route is caught as surely as one it
|
||||
// routed to the wrong place.
|
||||
// positions: writing it through the MODEL side of a host write and folding must equal patching it
|
||||
// into the folded block. Whole-block, not per-field — a member the patch forgot to route is
|
||||
// caught as surely as one it routed to the wrong place. Compared through live_params' own
|
||||
// field-wise operator==, NOT a memcmp: the block carries padding no copy is required to preserve,
|
||||
// so a byte compare here was non-deterministic. Both sides are the HOST's paths, which is what
|
||||
// the shell actually calls; that the host's value map agrees with the editor's everywhere it
|
||||
// should is the separate assertion below.
|
||||
static void testPatchingAControlEqualsFoldingTheModelAfterTheSameWrite() {
|
||||
const double kPositions[] = {0.0, 0.137, 0.5, 0.813, 1.0};
|
||||
for (const ParamRow& row : exposedParams()) {
|
||||
@@ -83,7 +89,7 @@ static void testPatchingAControlEqualsFoldingTheModelAfterTheSameWrite() {
|
||||
LiveValues block = modelBlock(dialledParams());
|
||||
const LiveValues before = block;
|
||||
CHECK_ID(!applyLiveParam(block, row.deck, 0.25, kRate), row.id);
|
||||
CHECK_ID(std::memcmp(&before, &block, sizeof(LiveValues)) == 0, row.id);
|
||||
CHECK_ID(before == block, row.id);
|
||||
continue;
|
||||
}
|
||||
for (double norm : kPositions) {
|
||||
@@ -91,14 +97,13 @@ static void testPatchingAControlEqualsFoldingTheModelAfterTheSameWrite() {
|
||||
if (row.deck == DeckParam::kKeyTrack) {
|
||||
written.keyTrack = reasampler::instrument::ui::keyTrackFromNorm(norm);
|
||||
} else {
|
||||
reasampler::instrument::ui::setDeckParam(row.deck, written.play, norm,
|
||||
/*segment=*/0);
|
||||
CHECK_ID(writeHostParam(row.deck, written.play, norm), row.id);
|
||||
}
|
||||
const LiveValues expected = modelBlock(written);
|
||||
|
||||
LiveValues patched = modelBlock(dialledParams());
|
||||
CHECK_ID(applyLiveParam(patched, row.deck, norm, kRate), row.id);
|
||||
CHECK_ID(std::memcmp(&expected, &patched, sizeof(LiveValues)) == 0, row.id);
|
||||
CHECK_ID(expected == patched, row.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -122,7 +127,7 @@ static void testAnUnexposedControlIsRefused() {
|
||||
const LiveValues before = block;
|
||||
CHECK(!applyLiveParam(block, DeckParam::kPlayMode, 1.0, kRate));
|
||||
CHECK(!applyLiveParam(block, DeckParam::kVoiceCount, 1.0, kRate));
|
||||
CHECK(std::memcmp(&before, &block, sizeof(LiveValues)) == 0);
|
||||
CHECK(before == block);
|
||||
}
|
||||
|
||||
// Every exposed control resolves to a home the host's read and write paths actually reach. The
|
||||
@@ -142,11 +147,45 @@ static void testEveryExposedControlHasAValueHome() {
|
||||
CHECK(instanceScalars == 2);
|
||||
}
|
||||
|
||||
// The host's value map is the editor's EXCEPT on the twelve curve exponents, where it skips the
|
||||
// knob detent — a drag affordance a lane has no use for and which would otherwise flatten a
|
||||
// knot-drawn near-neutral exponent to exactly 1.0 on any lane pass. Both halves are asserted: the
|
||||
// agreement everywhere else, and the difference exactly inside the detent band.
|
||||
static void testTheHostSkipsTheCurveDetentAndNothingElse() {
|
||||
using reasampler::instrument::ui::deckParamUnit;
|
||||
using reasampler::instrument::ui::storedFromNorm;
|
||||
using reasampler::instrument::ui::UnitCategory;
|
||||
const double kPositions[] = {0.0, 0.137, 0.4, 0.495, 0.5, 0.505, 0.6, 0.813, 1.0};
|
||||
for (const ParamRow& row : exposedParams()) {
|
||||
const bool exponent = deckParamUnit(row.deck) == UnitCategory::Exponent;
|
||||
for (double norm : kPositions) {
|
||||
const double editor = storedFromNorm(row.deck, norm);
|
||||
const double host = hostStoredFromNorm(row.deck, norm);
|
||||
// Inside the band but off centre is the ONE place they may differ, and must.
|
||||
const bool inBand = exponent && norm != 0.5 &&
|
||||
norm > 0.5 - reasampler::util::kCurveKnobDetent &&
|
||||
norm < 0.5 + reasampler::util::kCurveKnobDetent;
|
||||
if (inBand) {
|
||||
CHECK_ID(editor == reasampler::util::kCurveNeutral, row.id);
|
||||
CHECK_ID(host != editor, row.id);
|
||||
} else {
|
||||
CHECK_ID(host == editor, row.id);
|
||||
}
|
||||
}
|
||||
// The identity stays reachable from the host side too — that is what makes skipping the
|
||||
// detent a value-preserving change rather than a lost reset.
|
||||
if (exponent) {
|
||||
CHECK_ID(hostStoredFromNorm(row.deck, 0.5) == reasampler::util::kCurveNeutral, row.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int main() {
|
||||
testPatchingAControlEqualsFoldingTheModelAfterTheSameWrite();
|
||||
testTriggerLengthIsInertUnderADrawnEnvelope();
|
||||
testAnUnexposedControlIsRefused();
|
||||
testEveryExposedControlHasAValueHome();
|
||||
testTheHostSkipsTheCurveDetentAndNothingElse();
|
||||
if (g_fail == 0) std::printf("param_live: all tests passed\n");
|
||||
return g_fail == 0 ? 0 : 1;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -136,6 +136,28 @@ static void testTheHostAndTheEditorAgreeOnEveryDefaultPosition() {
|
||||
}
|
||||
}
|
||||
|
||||
// The criterion names the editor's DOUBLE-CLICK, and that gesture is resetDeckParam, not
|
||||
// deckParamNorm over a default-constructed set. Asserted directly: reset a DIALLED set and its
|
||||
// stored field must read back at exactly the normalized value the host resets to. (The two
|
||||
// instance scalars have no resetDeckParam entry — the shell resets those from InstrumentParams,
|
||||
// which the test above covers at the same position.)
|
||||
static void testADoubleClickResetLandsOnTheHostsDefaultNormalized() {
|
||||
using reasampler::instrument::ui::deckParamNorm;
|
||||
using reasampler::instrument::ui::resetDeckParam;
|
||||
using reasampler::instrument::ui::setDeckParam;
|
||||
for (const ParamRow& row : exposedParams()) {
|
||||
if (valueHomeFor(row.deck) == ValueHome::InstanceScalar) continue;
|
||||
PlaySeconds dialled;
|
||||
// Away from the default first, so a reset that did nothing at all cannot pass.
|
||||
setDeckParam(row.deck, dialled, 0.37, /*segment=*/0);
|
||||
CHECK_ID(deckParamNorm(row.deck, dialled) != defaultNormalized(row.deck) ||
|
||||
defaultNormalized(row.deck) == 0.37,
|
||||
row.id);
|
||||
resetDeckParam(row.deck, dialled);
|
||||
CHECK_ID(deckParamNorm(row.deck, dialled) == defaultNormalized(row.deck), row.id);
|
||||
}
|
||||
}
|
||||
|
||||
static void testTheFiltersFourTakeTheirStoredNormVerbatim() {
|
||||
// Their stored value IS the normalized one, so no taper may participate in their default:
|
||||
// this fails the moment someone routes them through toNormalized(toPlain(x)).
|
||||
@@ -218,6 +240,7 @@ int main() {
|
||||
testEveryUnitStringAndRangeMatchesTheSpecifiedTable();
|
||||
testEveryDefaultHasAnExactNormalizedPreimage();
|
||||
testTheHostAndTheEditorAgreeOnEveryDefaultPosition();
|
||||
testADoubleClickResetLandsOnTheHostsDefaultNormalized();
|
||||
testTheFiltersFourTakeTheirStoredNormVerbatim();
|
||||
testToPlainIsMonotoneAcrossTheWholeTravel();
|
||||
testTheEndpointsAreTheDeclaredPlainRange();
|
||||
|
||||
Reference in New Issue
Block a user