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:
2026-08-02 15:14:16 -04:00
parent c7afa3a80f
commit bfaa0f2614
38 changed files with 1742 additions and 218 deletions
+191
View File
@@ -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;
}