Report the instrument's automatable parameters to the host under a frozen id table, in signal-flow order, with real units
42 of 44 ids issued: pitch key-track and Trigger length stay reserved pending a live path. Master gain reclassified Live — it never reloaded.
This commit is contained in:
@@ -40,6 +40,10 @@ static void testEveryDeckControlIsClassifiedIntoOneOfTheThreeCommitTiers() {
|
||||
DeckParam::kFilterEnvAttackCurve, DeckParam::kFilterEnvDecayCurve,
|
||||
DeckParam::kFilterEnvReleaseCurve,
|
||||
DeckParam::kFilterTrigAttackCurve, DeckParam::kFilterTrigDecayCurve,
|
||||
// The one live control that does not ride the live block: a lock-free atomic the audio
|
||||
// thread applies as a post-sum multiply. The tier answers "does an edit reach the audio
|
||||
// without a reload", not "which mechanism carries it".
|
||||
DeckParam::kMasterGain,
|
||||
};
|
||||
for (DeckParam p : live) CHECK(deckParamCommit(p) == LiveCommit::Live);
|
||||
|
||||
@@ -60,7 +64,7 @@ static void testEveryDeckControlIsClassifiedIntoOneOfTheThreeCommitTiers() {
|
||||
DeckParam::kAmpEnvSelect, DeckParam::kPitchEnvSelect, DeckParam::kFilterEnvSelect,
|
||||
DeckParam::kAmpEnvMode, DeckParam::kPitchEnvMode, DeckParam::kFilterEnvMode,
|
||||
DeckParam::kVoiceCount, DeckParam::kVoiceMode,
|
||||
DeckParam::kMonoTrigger, DeckParam::kMasterGain, DeckParam::kLimiterEnable,
|
||||
DeckParam::kMonoTrigger, DeckParam::kLimiterEnable,
|
||||
DeckParam::kMasterMeter, DeckParam::kMasterGr,
|
||||
};
|
||||
for (DeckParam p : reloads) CHECK(deckParamCommit(p) == LiveCommit::Reload);
|
||||
@@ -96,7 +100,10 @@ static void testOnlyALiveControlsDragTakesTheLiveTier() {
|
||||
// it move a sounding note) nor as Reload (which would re-decode the WAV under a swept knob).
|
||||
CHECK(knob(DeckParam::kRate) == LiveCommit::NoteOnLatched);
|
||||
CHECK(knob(DeckParam::kTrigLength) == LiveCommit::Reload);
|
||||
CHECK(knob(DeckParam::kMasterGain) == LiveCommit::Reload);
|
||||
// Master gain is Live and reaches the audio BESIDE the live block rather than through it —
|
||||
// one atomic the audio thread applies as a post-sum multiply. Classifying it Reload would
|
||||
// claim a gain move re-decodes the WAV, which it never did.
|
||||
CHECK(knob(DeckParam::kMasterGain) == LiveCommit::Live);
|
||||
CHECK(knob(DeckParam::kAmpEnvSelect) == LiveCommit::Reload);
|
||||
// The shell's processor-side sentinels (preview velocity is -2) and any out-of-range id
|
||||
// are not parameter-set controls, so they must never reach the enum.
|
||||
|
||||
@@ -20,12 +20,6 @@ static int g_fail = 0;
|
||||
#define CHECK(cond) do { if(!(cond)) { \
|
||||
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
|
||||
|
||||
static std::string msLabel(double seconds) {
|
||||
char buf[24];
|
||||
formatEnvTimeMs(seconds, buf, sizeof(buf));
|
||||
return std::string(buf);
|
||||
}
|
||||
|
||||
// The stage-time ceiling has TWO names — the overlay's schematic domain and the knob's — and they
|
||||
// must be the same number or a maxed knob stops landing on the canvas edge. Asserted, not assumed.
|
||||
static void testTheTwoCeilingNamesAreOneNumber() {
|
||||
@@ -406,28 +400,6 @@ static void testTheFilterFourKeepTheirIdentityTaper() {
|
||||
}
|
||||
}
|
||||
|
||||
// One unit, everywhere, across the formatter's whole range: a sub-millisecond value keeps a
|
||||
// decimal rather than reading as a bare zero, and a multi-second one stays in ms rather than
|
||||
// switching units mid-deck.
|
||||
static void testTimeConstantsAlwaysReadInMilliseconds() {
|
||||
CHECK(msLabel(0.0) == "0.0 ms");
|
||||
CHECK(msLabel(0.0005) == "0.5 ms"); // sub-millisecond
|
||||
CHECK(msLabel(0.0094) == "9.4 ms");
|
||||
CHECK(msLabel(0.012) == "12 ms"); // the use case's own reading
|
||||
CHECK(msLabel(0.25) == "250 ms");
|
||||
CHECK(msLabel(1.5) == "1500 ms"); // multi-second, still ms
|
||||
CHECK(msLabel(kEnvTimeMaxSeconds) == "10000 ms");
|
||||
// The 10 ms hinge belongs to the integer form, not the decimal one.
|
||||
CHECK(msLabel(0.01) == "10 ms");
|
||||
CHECK(msLabel(0.0099) == "9.9 ms");
|
||||
|
||||
// Never overruns a short buffer, and always terminates.
|
||||
char tiny[4];
|
||||
std::memset(tiny, 'x', sizeof(tiny));
|
||||
formatEnvTimeMs(1.5, tiny, sizeof(tiny));
|
||||
CHECK(tiny[3] == '\0');
|
||||
}
|
||||
|
||||
int main() {
|
||||
testTheTwoCeilingNamesAreOneNumber();
|
||||
testNormRoundTripsThroughEveryValueDomain();
|
||||
@@ -442,7 +414,6 @@ int main() {
|
||||
testShiftSnapsToAWholeUnitOfTheDisplayedValue();
|
||||
testAValueStoredUnderTheOldCeilingIsReadNotRewritten();
|
||||
testTheFilterFourKeepTheirIdentityTaper();
|
||||
testTimeConstantsAlwaysReadInMilliseconds();
|
||||
if (g_fail) {
|
||||
std::printf("%d FAILURE(S)\n", g_fail);
|
||||
return 1;
|
||||
|
||||
@@ -90,18 +90,6 @@ static void testBelowFloorCollapsesToBottom() {
|
||||
CHECK(masterGainNormFromDb(kMasterGainMinDb + 1e-9) > 0.0);
|
||||
}
|
||||
|
||||
static void testLabels() {
|
||||
char buf[24];
|
||||
formatMasterGainLabel(0.0, buf, sizeof(buf));
|
||||
CHECK(std::strcmp(buf, "-inf") == 0);
|
||||
formatMasterGainLabel(1.0, buf, sizeof(buf));
|
||||
CHECK(std::strcmp(buf, "+24.0dB") == 0);
|
||||
formatMasterGainLabel(masterGainNormFromDb(0.0), buf, sizeof(buf));
|
||||
CHECK(std::strcmp(buf, "+0.0dB") == 0);
|
||||
formatMasterGainLabel(masterGainNormFromDb(-12.0), buf, sizeof(buf));
|
||||
CHECK(std::strcmp(buf, "-12.0dB") == 0);
|
||||
}
|
||||
|
||||
int main() {
|
||||
testBottomIsTrueSilence();
|
||||
testEndpoints();
|
||||
@@ -110,7 +98,6 @@ int main() {
|
||||
testMonotonic();
|
||||
testNonFiniteLinearClamps();
|
||||
testBelowFloorCollapsesToBottom();
|
||||
testLabels();
|
||||
if (g_fail) {
|
||||
std::printf("%d FAILURE(S)\n", g_fail);
|
||||
return 1;
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
// Standalone tests for the ONE formatter per unit category: the digit shapes each category
|
||||
// prints, and the property that makes the editor's knob label and the host's parameter string
|
||||
// identical — both call THIS function, over a plain value derived the way each surface derives
|
||||
// it. No VST3, no REAPER, no framework.
|
||||
|
||||
#include "../src/core/instrument/param/param_format.h"
|
||||
|
||||
#include "../src/core/instrument/engine/master_gain.h"
|
||||
#include "../src/core/instrument/param/param_id.h"
|
||||
#include "../src/core/instrument/ui/deck_values.h"
|
||||
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
|
||||
using namespace reasampler;
|
||||
using namespace reasampler::instrument::param;
|
||||
using reasampler::instrument::map::PlaySeconds;
|
||||
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)
|
||||
|
||||
static std::string digits(UnitKind kind, double plain) {
|
||||
char buf[24];
|
||||
formatPlain(kind, plain, buf, sizeof(buf));
|
||||
return std::string(buf);
|
||||
}
|
||||
|
||||
static void testEachCategoryPrintsItsSpecifiedShape() {
|
||||
// A time constant never switches to seconds, so the ceiling reads 10000 and not 10.
|
||||
CHECK(digits(UnitKind::Time, 0.5) == "0.5");
|
||||
CHECK(digits(UnitKind::Time, 3.0) == "3.0");
|
||||
CHECK(digits(UnitKind::Time, 10.0) == "10");
|
||||
CHECK(digits(UnitKind::Time, 104.0) == "104");
|
||||
CHECK(digits(UnitKind::Time, 10000.0) == "10000");
|
||||
// Semitones are always signed, including at zero — an unsigned "0.0" beside a "+3.5" reads
|
||||
// as a different kind of quantity.
|
||||
CHECK(digits(UnitKind::Semitones, 3.5) == "+3.5");
|
||||
CHECK(digits(UnitKind::Semitones, -12.0) == "-12.0");
|
||||
CHECK(digits(UnitKind::Semitones, 0.0) == "+0.0");
|
||||
CHECK(digits(UnitKind::PercentUnipolar, 100.0) == "100");
|
||||
CHECK(digits(UnitKind::PercentKeyTrack, 200.0) == "200");
|
||||
CHECK(digits(UnitKind::PercentBipolar, -40.0) == "-40");
|
||||
CHECK(digits(UnitKind::PercentBipolar, 40.0) == "+40");
|
||||
// Rate keeps a decimal: its snap grid is whole semitones, which do not land on integer
|
||||
// percent, so an integer display would print a snapped position the snap cannot produce.
|
||||
CHECK(digits(UnitKind::PercentRate, 105.946) == "105.9");
|
||||
CHECK(digits(UnitKind::PercentRate, 200.0) == "200.0");
|
||||
CHECK(digits(UnitKind::Decibels, 0.0) == "+0.0");
|
||||
CHECK(digits(UnitKind::Decibels, -12.0) == "-12.0");
|
||||
CHECK(digits(UnitKind::Decibels, -std::numeric_limits<double>::infinity()) == "-inf");
|
||||
// Cutoff's "k" abbreviation is retired — one static units string cannot switch with
|
||||
// magnitude, and keeping "12.8k" on one surface alone is the divergence this file forbids.
|
||||
CHECK(digits(UnitKind::Hertz, 240.0) == "240");
|
||||
CHECK(digits(UnitKind::Hertz, 12800.0) == "12800");
|
||||
// The caret on a curve dial is the editor's static cell chrome, never part of the value.
|
||||
CHECK(digits(UnitKind::Dimensionless, 1.0) == "1.00");
|
||||
CHECK(digits(UnitKind::Dimensionless, 0.1) == "0.10");
|
||||
}
|
||||
|
||||
static void testTheEditorAndTheHostPrintTheSameDigitsAtTheSameStoredValue() {
|
||||
// The host derives its plain value from the normalized one it holds; the editor derives its
|
||||
// from the STORED field, through the deck's own read. If those two derivations disagreed at
|
||||
// any reachable value the two surfaces would print different numbers for one control — this
|
||||
// is that property, swept over the whole travel of every exposed control.
|
||||
for (const ParamRow& row : exposedParams()) {
|
||||
if (row.deck == DeckParam::kMasterGain) continue; // not stored in PlaySeconds
|
||||
for (int step = 0; step <= 40; ++step) {
|
||||
const double norm = step / 40.0;
|
||||
PlaySeconds play;
|
||||
reasampler::instrument::ui::setDeckParam(row.deck, play, norm, /*segment=*/0);
|
||||
|
||||
char hostBuf[24];
|
||||
formatPlainFor(row.deck, toPlain(row.deck, norm), hostBuf, sizeof(hostBuf));
|
||||
|
||||
const double editorNorm =
|
||||
reasampler::instrument::ui::deckParamNorm(row.deck, play);
|
||||
char editorBuf[24];
|
||||
formatPlainFor(row.deck, toPlain(row.deck, editorNorm), editorBuf, sizeof(editorBuf));
|
||||
|
||||
if (storesNormalized(row.deck)) {
|
||||
// The filter's four store their position as a FLOAT, so a norm the host has sent
|
||||
// but we have not yet stored differs from the stored one by up to a float ulp.
|
||||
// At a value landing exactly on a display rounding boundary that is worth one
|
||||
// digit, so these four are held to the PLAIN value rather than to the string —
|
||||
// the derivation is still asserted to be one derivation.
|
||||
const double hostPlain = toPlain(row.deck, norm);
|
||||
const double editorPlain = toPlain(row.deck, editorNorm);
|
||||
const double tolerance = std::fabs(hostPlain) * 1e-6 + 1e-9;
|
||||
if (std::fabs(hostPlain - editorPlain) > tolerance) {
|
||||
std::printf("FAIL param %u at norm %.4f: host %.9g vs editor %.9g\n",
|
||||
row.id, norm, hostPlain, editorPlain);
|
||||
++g_fail;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (std::strcmp(hostBuf, editorBuf) != 0) {
|
||||
std::printf("FAIL param %u at norm %.4f: host \"%s\" vs editor \"%s\"\n",
|
||||
row.id, norm, hostBuf, editorBuf);
|
||||
++g_fail;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static void testMasterGainPrintsTheSameDigitsFromEitherSurface() {
|
||||
using reasampler::instrument::engine::masterGainLinearFromNorm;
|
||||
using reasampler::instrument::engine::masterGainNormFromLinear;
|
||||
for (int step = 0; step <= 40; ++step) {
|
||||
const double norm = step / 40.0;
|
||||
// The editor reads the processor's stored LINEAR gain back through the taper; the host
|
||||
// holds the normalized value directly.
|
||||
const double editorNorm = masterGainNormFromLinear(masterGainLinearFromNorm(norm));
|
||||
char hostBuf[24];
|
||||
char editorBuf[24];
|
||||
formatPlainFor(DeckParam::kMasterGain, toPlain(DeckParam::kMasterGain, norm),
|
||||
hostBuf, sizeof(hostBuf));
|
||||
formatPlainFor(DeckParam::kMasterGain, toPlain(DeckParam::kMasterGain, editorNorm),
|
||||
editorBuf, sizeof(editorBuf));
|
||||
if (std::strcmp(hostBuf, editorBuf) != 0) {
|
||||
std::printf("FAIL master gain at norm %.4f: host \"%s\" vs editor \"%s\"\n",
|
||||
norm, hostBuf, editorBuf);
|
||||
++g_fail;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static void testTypingBackADisplayedValueLandsOnIt() {
|
||||
// getParamValueByString's half: the digits the host just showed must parse to the same
|
||||
// plain value, with or without the unit a user may retype beside them.
|
||||
double plain = 0.0;
|
||||
CHECK(parsePlain(UnitKind::Time, "104", plain) && plain == 104.0);
|
||||
CHECK(parsePlain(UnitKind::Time, "104 ms", plain) && plain == 104.0);
|
||||
CHECK(parsePlain(UnitKind::Semitones, "+3.5", plain) && plain == 3.5);
|
||||
CHECK(parsePlain(UnitKind::Semitones, "-12.0st", plain) && plain == -12.0);
|
||||
CHECK(parsePlain(UnitKind::Hertz, "12800Hz", plain) && plain == 12800.0);
|
||||
CHECK(parsePlain(UnitKind::Decibels, "-inf", plain) && !std::isfinite(plain) && plain < 0.0);
|
||||
CHECK(!parsePlain(UnitKind::Time, "abc", plain));
|
||||
CHECK(!parsePlain(UnitKind::Time, nullptr, plain));
|
||||
}
|
||||
|
||||
static void testAShortBufferIsNeverOverrunAndAlwaysTerminates() {
|
||||
for (int kind = 0; kind <= static_cast<int>(UnitKind::Dimensionless); ++kind) {
|
||||
char tiny[4];
|
||||
std::memset(tiny, 'x', sizeof(tiny));
|
||||
formatPlain(static_cast<UnitKind>(kind), 1500.0, tiny, sizeof(tiny));
|
||||
CHECK(tiny[3] == '\0');
|
||||
}
|
||||
}
|
||||
|
||||
static void testEveryExposedParameterHasAFormatterThatWritesSomething() {
|
||||
for (const ParamRow& row : exposedParams()) {
|
||||
char buf[24];
|
||||
formatPlainFor(row.deck, toPlain(row.deck, 0.5), buf, sizeof(buf));
|
||||
if (buf[0] == '\0') {
|
||||
std::printf("FAIL param %u produced an empty string\n", row.id);
|
||||
++g_fail;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int main() {
|
||||
testEachCategoryPrintsItsSpecifiedShape();
|
||||
testTheEditorAndTheHostPrintTheSameDigitsAtTheSameStoredValue();
|
||||
testMasterGainPrintsTheSameDigitsFromEitherSurface();
|
||||
testTypingBackADisplayedValueLandsOnIt();
|
||||
testAShortBufferIsNeverOverrunAndAlwaysTerminates();
|
||||
testEveryExposedParameterHasAFormatterThatWritesSomething();
|
||||
if (g_fail == 0) std::printf("param_format: all tests passed\n");
|
||||
return g_fail == 0 ? 0 : 1;
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
// Standalone tests for the FOREVER-FROZEN VST3 parameter id table and the exposed set derived
|
||||
// from the commit predicate — no VST3, no REAPER, no framework.
|
||||
//
|
||||
// The asserted numbers are LITERALS on purpose. A test that recomputed them from cellIds, from
|
||||
// the enum's position, or from the table itself would defeat the freeze it exists to hold: the
|
||||
// point is that changing any id has to break this file.
|
||||
|
||||
#include "../src/core/instrument/param/param_id.h"
|
||||
|
||||
#include <cstdio>
|
||||
#include <set>
|
||||
#include <string>
|
||||
|
||||
using namespace reasampler;
|
||||
using namespace reasampler::instrument::param;
|
||||
using reasampler::instrument::ui::DeckParam;
|
||||
using reasampler::instrument::ui::LiveCommit;
|
||||
using reasampler::instrument::ui::deckParamCommit;
|
||||
|
||||
static int g_fail = 0;
|
||||
#define CHECK(cond) do { if(!(cond)) { \
|
||||
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
|
||||
|
||||
static void testEveryIdHoldsTheNumberItShippedWith() {
|
||||
// docs/product/parameter-automation.md 6.2, transcribed. If this list and the table
|
||||
// disagree, the table moved and every automation lane recorded against it now means
|
||||
// something else.
|
||||
struct Expect { ParamId id; DeckParam deck; };
|
||||
const Expect kExpected[] = {
|
||||
{1000, DeckParam::kKeyTrack},
|
||||
{1010, DeckParam::kRate},
|
||||
{1020, DeckParam::kPitch},
|
||||
{1100, DeckParam::kPitchEnvAttack},
|
||||
{1101, DeckParam::kPitchEnvAttackCurve},
|
||||
{1110, DeckParam::kPitchEnvHold},
|
||||
{1120, DeckParam::kPitchEnvDecay},
|
||||
{1121, DeckParam::kPitchEnvDecayCurve},
|
||||
{1130, DeckParam::kPitchEnvDepth},
|
||||
{1200, DeckParam::kFilterMorph},
|
||||
{1210, DeckParam::kFilterCutoff},
|
||||
{1220, DeckParam::kFilterQ},
|
||||
{1230, DeckParam::kFilterDrive},
|
||||
{1240, DeckParam::kFilterModAmt},
|
||||
{1250, DeckParam::kFilterVel},
|
||||
{1260, DeckParam::kFilterKeyTrack},
|
||||
{1300, DeckParam::kFilterEnvAttack},
|
||||
{1301, DeckParam::kFilterEnvAttackCurve},
|
||||
{1310, DeckParam::kFilterEnvHold},
|
||||
{1320, DeckParam::kFilterEnvDecay},
|
||||
{1321, DeckParam::kFilterEnvDecayCurve},
|
||||
{1330, DeckParam::kFilterEnvSustain},
|
||||
{1340, DeckParam::kFilterEnvRelease},
|
||||
{1341, DeckParam::kFilterEnvReleaseCurve},
|
||||
{1350, DeckParam::kFilterTrigAttack},
|
||||
{1351, DeckParam::kFilterTrigAttackCurve},
|
||||
{1360, DeckParam::kFilterTrigHold},
|
||||
{1370, DeckParam::kFilterTrigDecay},
|
||||
{1371, DeckParam::kFilterTrigDecayCurve},
|
||||
{1400, DeckParam::kAttack},
|
||||
{1401, DeckParam::kAttackCurve},
|
||||
{1410, DeckParam::kHold},
|
||||
{1420, DeckParam::kDecay},
|
||||
{1421, DeckParam::kDecayCurve},
|
||||
{1430, DeckParam::kSustain},
|
||||
{1440, DeckParam::kRelease},
|
||||
{1441, DeckParam::kReleaseCurve},
|
||||
{1450, DeckParam::kTrigLength},
|
||||
{1460, DeckParam::kTrigAttack},
|
||||
{1461, DeckParam::kTrigAttackCurve},
|
||||
{1470, DeckParam::kTrigHold},
|
||||
{1480, DeckParam::kTrigDecay},
|
||||
{1481, DeckParam::kTrigDecayCurve},
|
||||
{1700, DeckParam::kMasterGain},
|
||||
};
|
||||
const std::size_t expectedCount = sizeof(kExpected) / sizeof(kExpected[0]);
|
||||
CHECK(expectedCount == 44);
|
||||
CHECK(paramTable().size() == expectedCount);
|
||||
if (paramTable().size() != expectedCount) return;
|
||||
for (std::size_t i = 0; i < expectedCount; ++i) {
|
||||
CHECK(paramTable()[i].id == kExpected[i].id);
|
||||
CHECK(paramTable()[i].deck == kExpected[i].deck);
|
||||
CHECK(paramIdFor(kExpected[i].deck) == kExpected[i].id);
|
||||
}
|
||||
}
|
||||
|
||||
static void testIdsAreUniqueAscendingInBlockAndOnStep() {
|
||||
std::set<ParamId> seen;
|
||||
ParamId previous = 0;
|
||||
for (const ParamRow& row : paramTable()) {
|
||||
CHECK(seen.insert(row.id).second); // unique
|
||||
CHECK(row.id > previous); // ascending == presentation order
|
||||
previous = row.id;
|
||||
CHECK(row.id >= 1000); // 0 is a plausible accident, never an id
|
||||
const ParamId withinBlock = row.id % 100;
|
||||
// On the step, or one past it — a curve dial takes its outer knob's id + 1, and only a
|
||||
// curve dial may.
|
||||
const bool onStep = withinBlock % 10 == 0;
|
||||
const bool innerDial = withinBlock % 10 == 1;
|
||||
CHECK(onStep || innerDial);
|
||||
}
|
||||
}
|
||||
|
||||
static void testAnInnerDialSitsBesideTheKnobItShapes() {
|
||||
for (const ParamRow& row : paramTable()) {
|
||||
if (row.id % 10 != 1) continue;
|
||||
// Its outer knob is the row numbered one below it, and curveParamFor must agree that the
|
||||
// dial belongs to that knob.
|
||||
const ParamRow* outer = nullptr;
|
||||
for (const ParamRow& candidate : paramTable()) {
|
||||
if (candidate.id == row.id - 1) outer = &candidate;
|
||||
}
|
||||
CHECK(outer != nullptr);
|
||||
if (outer) CHECK(reasampler::instrument::ui::curveParamFor(outer->deck) == row.deck);
|
||||
}
|
||||
}
|
||||
|
||||
static void testABlockCarriesOnlyItsOwnGroup() {
|
||||
for (const ParamRow& row : paramTable()) {
|
||||
const ParamId block = row.id / 100 * 100;
|
||||
switch (block) {
|
||||
case 1000: CHECK(row.unit == kUnitPitch); break;
|
||||
case 1100: CHECK(row.unit == kUnitPitchEnv); break;
|
||||
case 1200: CHECK(row.unit == kUnitFilter); break;
|
||||
case 1300: CHECK(row.unit == kUnitFilterEnv); break;
|
||||
case 1400: CHECK(row.unit == kUnitAmp); break;
|
||||
case 1700: CHECK(row.unit == kUnitMaster); break;
|
||||
default: CHECK(false); break; // 1500/1600 are reserved and must stay empty
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static void testTheExposedSetIsExactlyThePredicateAnswer() {
|
||||
// Asserted against deckParamCommit, never against a literal count: the list follows the
|
||||
// predicate, and the predicate is never bent to fill the list.
|
||||
for (const ParamRow& row : paramTable()) {
|
||||
const bool live = deckParamCommit(row.deck) != LiveCommit::Reload;
|
||||
CHECK(isExposed(row.deck) == live);
|
||||
const bool listed = exposedRowFor(row.id) != nullptr;
|
||||
CHECK(listed == live);
|
||||
}
|
||||
for (const ParamRow& row : exposedParams()) {
|
||||
CHECK(deckParamCommit(row.deck) != LiveCommit::Reload);
|
||||
}
|
||||
// Every control the predicate calls live must HAVE a row — a live control with no number is
|
||||
// a parameter the host can never be told about.
|
||||
for (int i = 0; i < static_cast<int>(DeckParam::kCount); ++i) {
|
||||
const auto deck = static_cast<DeckParam>(i);
|
||||
if (deckParamCommit(deck) == LiveCommit::Reload) continue;
|
||||
CHECK(paramIdFor(deck) != 0);
|
||||
}
|
||||
}
|
||||
|
||||
static void testAReservedRowIsNumberedButNotIssued() {
|
||||
// Key-track (pitch) and Trigger length are note-on-latch candidates that still route through
|
||||
// the reload tier, so they are NOT issued to the host today. Their numbers stay reserved
|
||||
// rather than retired: nothing shipped under them, so a later promotion issues the same id
|
||||
// and no other id moves.
|
||||
CHECK(paramIdFor(DeckParam::kKeyTrack) == 1000);
|
||||
CHECK(paramIdFor(DeckParam::kTrigLength) == 1450);
|
||||
CHECK(!isExposed(DeckParam::kKeyTrack));
|
||||
CHECK(!isExposed(DeckParam::kTrigLength));
|
||||
CHECK(exposedRowFor(1000) == nullptr);
|
||||
CHECK(exposedRowFor(1450) == nullptr);
|
||||
// Master gain IS issued: one atomic store the audio thread picks up next block is the live
|
||||
// tier by that tier's own definition.
|
||||
CHECK(isExposed(DeckParam::kMasterGain));
|
||||
CHECK(exposedRowFor(1700) != nullptr);
|
||||
CHECK(exposedParams().size() == paramTable().size() - 2);
|
||||
}
|
||||
|
||||
static void testEveryRowCarriesADistinctTitleAndShortTitle() {
|
||||
std::set<std::string> titles;
|
||||
std::set<std::string> shortTitles;
|
||||
for (const ParamRow& row : paramTable()) {
|
||||
CHECK(row.title && row.title[0] != '\0');
|
||||
CHECK(row.shortTitle && row.shortTitle[0] != '\0');
|
||||
CHECK(std::string(row.title) != std::string(row.shortTitle));
|
||||
CHECK(titles.insert(row.title).second);
|
||||
CHECK(shortTitles.insert(row.shortTitle).second);
|
||||
}
|
||||
}
|
||||
|
||||
int main() {
|
||||
testEveryIdHoldsTheNumberItShippedWith();
|
||||
testIdsAreUniqueAscendingInBlockAndOnStep();
|
||||
testAnInnerDialSitsBesideTheKnobItShapes();
|
||||
testABlockCarriesOnlyItsOwnGroup();
|
||||
testTheExposedSetIsExactlyThePredicateAnswer();
|
||||
testAReservedRowIsNumberedButNotIssued();
|
||||
testEveryRowCarriesADistinctTitleAndShortTitle();
|
||||
if (g_fail == 0) std::printf("param_id: all tests passed\n");
|
||||
return g_fail == 0 ? 0 : 1;
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
// Standalone tests for the plain-value layer: unit strings, plain ranges, monotonicity, the
|
||||
// exact-preimage requirement at every default, and the read-side-only property of the filter's
|
||||
// four — no VST3, no REAPER, no framework.
|
||||
|
||||
#include "../src/core/instrument/param/param_units.h"
|
||||
|
||||
#include "../src/core/instrument/param/param_id.h"
|
||||
#include "../src/core/instrument/engine/filter/filter_params.h"
|
||||
#include "../src/core/instrument/map/play_seconds.h"
|
||||
#include "../src/core/instrument/ui/deck_values.h"
|
||||
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
|
||||
using namespace reasampler;
|
||||
using namespace reasampler::instrument::param;
|
||||
using reasampler::instrument::map::PlaySeconds;
|
||||
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)
|
||||
|
||||
static void testEveryUnitStringAndRangeMatchesTheSpecifiedTable() {
|
||||
struct Expect { ParamId id; const char* units; double min; double max; };
|
||||
// docs/product/parameter-automation.md 6.7.1, per parameter rather than per category, so a
|
||||
// control silently reclassified into the wrong category fails here.
|
||||
const Expect kExpected[] = {
|
||||
{kParamRate, "%", 50.0, 200.0},
|
||||
{kParamPitchOffset, "st", -24.0, 24.0},
|
||||
{kParamPitchEnvAttack, "ms", 0.0, 10000.0},
|
||||
{kParamPitchEnvAttackCurve, "", 0.1, 10.0},
|
||||
{kParamPitchEnvHold, "%", 0.0, 100.0},
|
||||
{kParamPitchEnvDecay, "ms", 0.0, 10000.0},
|
||||
{kParamPitchEnvDecayCurve, "", 0.1, 10.0},
|
||||
{kParamPitchEnvDepth, "st", -24.0, 24.0},
|
||||
{kParamFilterMorph, "%", 0.0, 100.0},
|
||||
{kParamFilterCutoff, "Hz", 20.0, 20000.0},
|
||||
{kParamFilterQ, "", 0.1, 10.0},
|
||||
{kParamFilterDrive, "", 0.0, 4.0},
|
||||
{kParamFilterModAmount, "%", -100.0, 100.0},
|
||||
{kParamFilterVelAmount, "%", -100.0, 100.0},
|
||||
{kParamKeyTrackFilter, "%", 0.0, 200.0},
|
||||
{kParamFilterEnvAttack, "ms", 0.0, 10000.0},
|
||||
{kParamFilterEnvAttackCurve, "", 0.1, 10.0},
|
||||
{kParamFilterEnvHold, "ms", 0.0, 10000.0},
|
||||
{kParamFilterEnvDecay, "ms", 0.0, 10000.0},
|
||||
{kParamFilterEnvDecayCurve, "", 0.1, 10.0},
|
||||
{kParamFilterEnvSustain, "%", 0.0, 100.0},
|
||||
{kParamFilterEnvRelease, "ms", 0.0, 10000.0},
|
||||
{kParamFilterEnvReleaseCurve, "", 0.1, 10.0},
|
||||
{kParamFilterTrigAttack, "ms", 0.0, 10000.0},
|
||||
{kParamFilterTrigAttackCurve, "", 0.1, 10.0},
|
||||
{kParamFilterTrigHold, "%", 0.0, 100.0},
|
||||
{kParamFilterTrigDecay, "ms", 0.0, 10000.0},
|
||||
{kParamFilterTrigDecayCurve, "", 0.1, 10.0},
|
||||
{kParamAmpAttack, "ms", 0.0, 10000.0},
|
||||
{kParamAmpAttackCurve, "", 0.1, 10.0},
|
||||
{kParamAmpHold, "ms", 0.0, 10000.0},
|
||||
{kParamAmpDecay, "ms", 0.0, 10000.0},
|
||||
{kParamAmpDecayCurve, "", 0.1, 10.0},
|
||||
{kParamAmpSustain, "%", 0.0, 100.0},
|
||||
{kParamAmpRelease, "ms", 0.0, 10000.0},
|
||||
{kParamAmpReleaseCurve, "", 0.1, 10.0},
|
||||
{kParamAmpTrigAttack, "ms", 0.0, 10000.0},
|
||||
{kParamAmpTrigAttackCurve, "", 0.1, 10.0},
|
||||
{kParamAmpTrigHold, "%", 0.0, 100.0},
|
||||
{kParamAmpTrigDecay, "ms", 0.0, 10000.0},
|
||||
{kParamAmpTrigDecayCurve, "", 0.1, 10.0},
|
||||
{kParamMasterGain, "dB", -60.0, 24.0},
|
||||
};
|
||||
const std::size_t count = sizeof(kExpected) / sizeof(kExpected[0]);
|
||||
// Every exposed parameter is covered, and nothing else is listed.
|
||||
CHECK(count == exposedParams().size());
|
||||
for (const Expect& e : kExpected) {
|
||||
const ParamRow* row = exposedRowFor(e.id);
|
||||
CHECK_ID(row != nullptr, e.id);
|
||||
if (!row) continue;
|
||||
CHECK_ID(std::strcmp(unitStringFor(row->deck), e.units) == 0, e.id);
|
||||
const PlainRange range = plainRangeFor(row->deck);
|
||||
// Relative rather than exact: the filter's three endpoints are float constants, so
|
||||
// 0.1f widened is not the double 0.1. A wrong RANGE — 0.2, or 20 — still fails.
|
||||
CHECK_ID(std::fabs(range.min - e.min) <= std::fabs(e.min) * 1e-6 + 1e-12, e.id);
|
||||
CHECK_ID(std::fabs(range.max - e.max) <= std::fabs(e.max) * 1e-6 + 1e-12, e.id);
|
||||
}
|
||||
}
|
||||
|
||||
static void testEveryDefaultHasAnExactNormalizedPreimage() {
|
||||
// A host's reset-to-default arrives as toPlain(defaultNormalizedValue) and there is no
|
||||
// editor-side taper bypass available to it. Exactly equal, not near: a gain landing a hair
|
||||
// off unity is an audible error, and a stage time landing a hair off its default is a value
|
||||
// the user never dialled.
|
||||
for (const ParamRow& row : exposedParams()) {
|
||||
const double norm = defaultNormalized(row.deck);
|
||||
CHECK_ID(norm >= 0.0 && norm <= 1.0, row.id);
|
||||
CHECK_ID(toPlain(row.deck, norm) == defaultPlain(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)).
|
||||
PlaySeconds defaults;
|
||||
const DeckParam kStoredNorm[] = {DeckParam::kFilterMorph, DeckParam::kFilterCutoff,
|
||||
DeckParam::kFilterQ, DeckParam::kFilterDrive};
|
||||
for (DeckParam deck : kStoredNorm) {
|
||||
CHECK(storesNormalized(deck));
|
||||
const float* stored = reasampler::instrument::ui::deckFloatField(deck, defaults);
|
||||
CHECK(stored != nullptr);
|
||||
if (stored) CHECK(defaultNormalized(deck) == static_cast<double>(*stored));
|
||||
}
|
||||
// And nothing else claims to store its norm — a control wrongly in that set would silently
|
||||
// skip the taper on the reset path.
|
||||
for (const ParamRow& row : exposedParams()) {
|
||||
const bool listed = row.deck == DeckParam::kFilterMorph ||
|
||||
row.deck == DeckParam::kFilterCutoff ||
|
||||
row.deck == DeckParam::kFilterQ ||
|
||||
row.deck == DeckParam::kFilterDrive;
|
||||
CHECK_ID(storesNormalized(row.deck) == listed, row.id);
|
||||
}
|
||||
}
|
||||
|
||||
static void testToPlainIsMonotoneAcrossTheWholeTravel() {
|
||||
// Monotonicity is required everywhere; round-trip exactness at an arbitrary norm is required
|
||||
// NOWHERE and is deliberately not asserted — no log map delivers it in double, and demanding
|
||||
// it would rule out the taper the range needs.
|
||||
for (const ParamRow& row : exposedParams()) {
|
||||
double previous = toPlain(row.deck, 0.0);
|
||||
for (int step = 1; step <= 200; ++step) {
|
||||
const double plain = toPlain(row.deck, step / 200.0);
|
||||
CHECK_ID(plain >= previous, row.id);
|
||||
previous = plain;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static void testTheEndpointsAreTheDeclaredPlainRange() {
|
||||
for (const ParamRow& row : exposedParams()) {
|
||||
const PlainRange range = plainRangeFor(row.deck);
|
||||
const double top = toPlain(row.deck, 1.0);
|
||||
CHECK_ID(std::fabs(top - range.max) <= std::fabs(range.max) * 1e-6 + 1e-9, row.id);
|
||||
if (row.deck == DeckParam::kMasterGain) {
|
||||
// Norm 0 is TRUE silence, not the -60 dB floor — the one plain value outside the
|
||||
// declared range, and the reason the dB formatter has an -inf case at all.
|
||||
CHECK(!std::isfinite(toPlain(row.deck, 0.0)));
|
||||
continue;
|
||||
}
|
||||
const double bottom = toPlain(row.deck, 0.0);
|
||||
CHECK_ID(std::fabs(bottom - range.min) <= std::fabs(range.min) * 1e-6 + 1e-9, row.id);
|
||||
}
|
||||
}
|
||||
|
||||
static void testNoExposedControlIsDiscrete() {
|
||||
// This is what makes "stepCount = 0 on all of them" structural rather than lucky: stepCount
|
||||
// is only meaningful for a discrete control, and every discrete control is reload or rebuild
|
||||
// tier and therefore never reaches the list. UnitCategory::None is the deck's own name for
|
||||
// "no continuous unit" — toggles, radios, the curve-popup cells, the integer voice count.
|
||||
for (const ParamRow& row : exposedParams()) {
|
||||
CHECK_ID(reasampler::instrument::ui::deckParamUnit(row.deck) !=
|
||||
reasampler::instrument::ui::UnitCategory::None,
|
||||
row.id);
|
||||
}
|
||||
}
|
||||
|
||||
static void testTheAddedDriveInverseUndoesTheFrozenLaw() {
|
||||
using reasampler::instrument::engine::filter::filterDriveDepthFromNorm;
|
||||
using reasampler::instrument::engine::filter::filterNormFromDriveDepth;
|
||||
for (int step = 0; step <= 100; ++step) {
|
||||
const float norm = static_cast<float>(step) / 100.0f;
|
||||
const float back = filterNormFromDriveDepth(filterDriveDepthFromNorm(norm));
|
||||
CHECK(std::fabs(back - norm) < 1e-6f);
|
||||
}
|
||||
CHECK(filterNormFromDriveDepth(0.0f) == 0.0f);
|
||||
CHECK(filterNormFromDriveDepth(-1.0f) == 0.0f);
|
||||
CHECK(filterNormFromDriveDepth(1000.0f) == 1.0f);
|
||||
}
|
||||
|
||||
int main() {
|
||||
testEveryUnitStringAndRangeMatchesTheSpecifiedTable();
|
||||
testEveryDefaultHasAnExactNormalizedPreimage();
|
||||
testTheFiltersFourTakeTheirStoredNormVerbatim();
|
||||
testToPlainIsMonotoneAcrossTheWholeTravel();
|
||||
testTheEndpointsAreTheDeclaredPlainRange();
|
||||
testNoExposedControlIsDiscrete();
|
||||
testTheAddedDriveInverseUndoesTheFrozenLaw();
|
||||
if (g_fail == 0) std::printf("param_units: all tests passed\n");
|
||||
return g_fail == 0 ? 0 : 1;
|
||||
}
|
||||
Reference in New Issue
Block a user