192 lines
8.8 KiB
C++
192 lines
8.8 KiB
C++
// 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"
|
|
|
|
#include "../src/core/instrument/param/param_id.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 "../src/core/util/curve_law.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::PlaySeconds;
|
|
using reasampler::instrument::map::resolvePlay;
|
|
using reasampler::instrument::ui::DeckParam;
|
|
|
|
static int g_fail = 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)
|
|
#define CHECK(cond) do { if(!(cond)) { \
|
|
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
|
|
|
|
namespace {
|
|
|
|
constexpr int kRate = 48000;
|
|
|
|
// The MODEL path, verbatim: the write the editor makes, resolved and folded the way every
|
|
// publisher folds it. This is the reference the patch is measured against.
|
|
LiveValues modelBlock(const InstrumentParams& params) {
|
|
return foldLive(resolvePlay(params.play, kRate), params.keyTrack);
|
|
}
|
|
|
|
// A parameter set deliberately away from its defaults, so an equivalence that only holds at the
|
|
// default cannot pass. Not every field — just enough that a mis-routed patch lands on a value
|
|
// that differs from the one it should have written.
|
|
InstrumentParams dialledParams() {
|
|
InstrumentParams p;
|
|
p.keyTrack = 1.5;
|
|
p.play.adsr.attackSeconds = 0.31;
|
|
p.play.adsr.holdSeconds = 0.07;
|
|
p.play.adsr.decaySeconds = 0.44;
|
|
p.play.adsr.sustainLevel = 0.62;
|
|
p.play.adsr.releaseSeconds = 0.9;
|
|
p.play.adsr.attackCurve = 2.5;
|
|
p.play.trigger.lengthFraction = 0.4;
|
|
p.play.trigAhd.attackSeconds = 0.12;
|
|
p.play.trigAhd.holdFraction = 0.3;
|
|
p.play.playRate = 1.2;
|
|
p.play.pitchOffsetSemitones = -5.0;
|
|
p.play.pitchEnv.enabled = true;
|
|
p.play.pitchEnv.peakSemitones = 7.0;
|
|
p.play.pitchEnv.shape.attackSeconds = 0.02;
|
|
p.play.filter.enabled = true;
|
|
p.play.filter.settings.cutoffNorm = 0.42f;
|
|
p.play.filter.settings.resonanceNorm = 0.66f;
|
|
p.play.filter.modAmount = -0.4;
|
|
p.play.filter.velAmount = 0.25;
|
|
p.play.filter.keyTrack = 0.75;
|
|
p.play.filter.env.attackSeconds = 0.05;
|
|
p.play.filter.trigEnv.decaySeconds = 0.6;
|
|
return p;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
// THE assertion this module exists for. For every exposed control and several normalized
|
|
// 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()) {
|
|
// Master gain is not carried by the block at all — the processor's own atomic is its
|
|
// route to the audio, and the patch reports that by refusing it.
|
|
if (row.deck == DeckParam::kMasterGain) {
|
|
LiveValues block = modelBlock(dialledParams());
|
|
const LiveValues before = block;
|
|
CHECK_ID(!applyLiveParam(block, row.deck, 0.25, kRate), row.id);
|
|
CHECK_ID(before == block, row.id);
|
|
continue;
|
|
}
|
|
for (double norm : kPositions) {
|
|
InstrumentParams written = dialledParams();
|
|
if (row.deck == DeckParam::kKeyTrack) {
|
|
written.keyTrack = reasampler::instrument::ui::keyTrackFromNorm(norm);
|
|
} else {
|
|
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(expected == patched, row.id);
|
|
}
|
|
}
|
|
}
|
|
|
|
// A drawn contour makes Trigger length inert — the editor's knob goes dead and the fold pins the
|
|
// fraction at 1.0. A host lane pointed at it must be equally inert, or automation would re-open a
|
|
// control the model says is closed.
|
|
static void testTriggerLengthIsInertUnderADrawnEnvelope() {
|
|
InstrumentParams p = dialledParams();
|
|
p.play.ampSpline.mode = reasampler::EnvMode::Spline;
|
|
LiveValues block = modelBlock(p);
|
|
CHECK(block.splineActive);
|
|
CHECK(block.lengthFraction == 1.0);
|
|
CHECK(applyLiveParam(block, DeckParam::kTrigLength, 0.2, kRate));
|
|
CHECK(block.lengthFraction == 1.0);
|
|
}
|
|
|
|
// A control with no parameter row is refused rather than silently landing somewhere.
|
|
static void testAnUnexposedControlIsRefused() {
|
|
LiveValues block = modelBlock(dialledParams());
|
|
const LiveValues before = block;
|
|
CHECK(!applyLiveParam(block, DeckParam::kPlayMode, 1.0, kRate));
|
|
CHECK(!applyLiveParam(block, DeckParam::kVoiceCount, 1.0, kRate));
|
|
CHECK(before == block);
|
|
}
|
|
|
|
// Every exposed control resolves to a home the host's read and write paths actually reach. The
|
|
// guard the promotion of pitch key-track needed: id 1000 was issued against a control whose value
|
|
// is not in PlaySeconds, and nothing failed to compile.
|
|
static void testEveryExposedControlHasAValueHome() {
|
|
for (const ParamRow& row : exposedParams()) {
|
|
CHECK_ID(valueHomeFor(row.deck) != ValueHome::None, row.id);
|
|
}
|
|
// And the instance-scalar set is exactly the two the shell branches on by name.
|
|
CHECK(valueHomeFor(DeckParam::kMasterGain) == ValueHome::InstanceScalar);
|
|
CHECK(valueHomeFor(DeckParam::kKeyTrack) == ValueHome::InstanceScalar);
|
|
int instanceScalars = 0;
|
|
for (const ParamRow& row : paramTable()) {
|
|
if (valueHomeFor(row.deck) == ValueHome::InstanceScalar) ++instanceScalars;
|
|
}
|
|
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;
|
|
}
|