// Standalone tests for reasampler::instrument::ui::deck_values — no VST3, no REAPER, no // framework. Covers the deck's parameter-set binding: the norm <-> stored-value round trip on a // representative control of each domain, the DOUBLE-CLICK RESET (each ring of a dual-ring knob // resetting only its own field), and the ms time-constant formatter across its whole range. #include "../src/core/instrument/ui/deck_values.h" #include #include #include using namespace reasampler; using namespace reasampler::instrument::ui; 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); } // Every domain the binding maps: a stage time over the seconds ceiling, a level, a fraction, // a normalized filter position, a bipolar depth, and a curve exponent over its log travel. static void testNormRoundTripsThroughEveryValueDomain() { PlaySeconds p; setDeckParam(DeckParam::kAttack, p, 0.25, 0); CHECK(p.adsr.attackSeconds == 0.25 * kEnvTimeMaxSeconds); CHECK(deckParamNorm(DeckParam::kAttack, p) == 0.25); setDeckParam(DeckParam::kSustain, p, 0.4, 0); CHECK(p.adsr.sustainLevel == 0.4); CHECK(deckParamNorm(DeckParam::kSustain, p) == 0.4); setDeckParam(DeckParam::kTrigHold, p, 0.75, 0); CHECK(p.trigAhd.holdFraction == 0.75); CHECK(deckParamNorm(DeckParam::kTrigHold, p) == 0.75); // Named field, not just a round trip: cutoff and morph are both normalized positions with // the same 1.0 default, so a getter+setter pair that swapped them would round-trip cleanly. setDeckParam(DeckParam::kFilterCutoff, p, 0.25, 0); CHECK(p.filter.settings.cutoffNorm == 0.25f); CHECK(p.filter.settings.morphNorm == 1.0f); CHECK(deckParamNorm(DeckParam::kFilterCutoff, p) == 0.25); // Bipolar: the centre detent is exact in BOTH directions, so a knob parked at centre // persists no depth at all. setDeckParam(DeckParam::kFilterModAmt, p, 0.5, 0); CHECK(p.filter.modAmount == 0.0); CHECK(deckParamNorm(DeckParam::kFilterModAmt, p) == 0.5); setDeckParam(DeckParam::kFilterModAmt, p, 1.0, 0); CHECK(p.filter.modAmount == 1.0); // A curve exponent off neutral survives the round trip; the centre snaps to exactly 1.0. setDeckParam(DeckParam::kAttackCurve, p, 1.0, 0); CHECK(p.adsr.attackCurve > 1.0); CHECK(deckParamNorm(DeckParam::kAttackCurve, p) == 1.0); setDeckParam(DeckParam::kAttackCurve, p, 0.5, 0); CHECK(p.adsr.attackCurve == 1.0); // Out-of-range norms clamp rather than writing an out-of-domain param. setDeckParam(DeckParam::kDecay, p, 2.0, 0); CHECK(p.adsr.decaySeconds == kEnvTimeMaxSeconds); setDeckParam(DeckParam::kDecay, p, -1.0, 0); CHECK(p.adsr.decaySeconds == 0.0); } // The dual-ring reset contract: the outer ring resets the stage VALUE and the inner dial resets // the EXPONENT, each leaving the other exactly as it was. Both fields are asserted in both // directions — checking only the field that changed would pass even if the reset clobbered its // neighbour. static void testResetTouchesOnlyItsOwnRingOnADualRingKnob() { const PlaySeconds defaults; const struct { DeckParam knob; DeckParam curve; } pairs[] = { {DeckParam::kAttack, DeckParam::kAttackCurve}, {DeckParam::kDecay, DeckParam::kDecayCurve}, {DeckParam::kRelease, DeckParam::kReleaseCurve}, {DeckParam::kTrigAttack, DeckParam::kTrigAttackCurve}, {DeckParam::kPitchEnvDecay, DeckParam::kPitchEnvDecayCurve}, {DeckParam::kFilterEnvRelease, DeckParam::kFilterEnvReleaseCurve}, }; for (const auto& pr : pairs) { // Dial BOTH rings well away from their defaults. PlaySeconds p; setDeckParam(pr.knob, p, 0.6, 0); setDeckParam(pr.curve, p, 0.9, 0); const double dialledValue = deckParamNorm(pr.knob, p); const double dialledCurve = deckParamNorm(pr.curve, p); CHECK(dialledValue != deckParamNorm(pr.knob, defaults)); CHECK(dialledCurve != deckParamNorm(pr.curve, defaults)); // INNER: the exponent goes to exactly the linear neutral, the value does not move. PlaySeconds inner = p; resetDeckParam(pr.curve, inner); CHECK(deckParamNorm(pr.curve, inner) == deckParamNorm(pr.curve, defaults)); CHECK(deckParamNorm(pr.curve, inner) == 0.5); // the exponent itself is 1.0 CHECK(deckParamNorm(pr.knob, inner) == dialledValue); // OUTER: the value goes to its default, the exponent does not move. PlaySeconds outer = p; resetDeckParam(pr.knob, outer); CHECK(deckParamNorm(pr.knob, outer) == deckParamNorm(pr.knob, defaults)); CHECK(deckParamNorm(pr.curve, outer) == dialledCurve); } } // The exponent reset is specified as EXACTLY 1.0 — the identity curveMap short-circuits on // (curve_law.h), not merely something that rounds to it. static void testInnerResetLandsOnTheExactLinearNeutral() { PlaySeconds p; setDeckParam(DeckParam::kAttackCurve, p, 0.2, 0); CHECK(p.adsr.attackCurve < 1.0); resetDeckParam(DeckParam::kAttackCurve, p); CHECK(p.adsr.attackCurve == 1.0); setDeckParam(DeckParam::kFilterTrigDecayCurve, p, 0.95, 0); CHECK(p.filter.trigEnv.decayCurve > 1.0); resetDeckParam(DeckParam::kFilterTrigDecayCurve, p); CHECK(p.filter.trigEnv.decayCurve == 1.0); } // A reset lands on the field's own stored default, EXACTLY — the defaults are read off a fresh // PlaySeconds and arrive through the norm round trip, so the two stage times whose defaults are // neither 0 nor 1 are the cases that actually exercise that exactness (see resetDeckParam's // note on what the seconds ceiling has to be for it to hold). static void testResetLandsOnTheStoredDefaultOfEachControl() { const PlaySeconds defaults; PlaySeconds p; setDeckParam(DeckParam::kSustain, p, 0.1, 0); setDeckParam(DeckParam::kTrigLength, p, 0.3, 0); setDeckParam(DeckParam::kFilterKeyTrack, p, 0.9, 0); setDeckParam(DeckParam::kPitchEnvDepth, p, 1.0, 0); setDeckParam(DeckParam::kAttack, p, 0.5, 0); setDeckParam(DeckParam::kRelease, p, 0.5, 0); CHECK(p.adsr.attackSeconds != defaults.adsr.attackSeconds); CHECK(p.adsr.releaseSeconds != defaults.adsr.releaseSeconds); resetDeckParam(DeckParam::kSustain, p); resetDeckParam(DeckParam::kTrigLength, p); resetDeckParam(DeckParam::kFilterKeyTrack, p); resetDeckParam(DeckParam::kPitchEnvDepth, p); resetDeckParam(DeckParam::kAttack, p); resetDeckParam(DeckParam::kRelease, p); CHECK(p.adsr.sustainLevel == defaults.adsr.sustainLevel); CHECK(p.trigger.lengthFraction == defaults.trigger.lengthFraction); CHECK(p.filter.keyTrack == defaults.filter.keyTrack); CHECK(p.pitchEnv.peakSemitones == defaults.pitchEnv.peakSemitones); CHECK(p.adsr.attackSeconds == defaults.adsr.attackSeconds); CHECK(p.adsr.releaseSeconds == defaults.adsr.releaseSeconds); } // 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) == "2000 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() { testNormRoundTripsThroughEveryValueDomain(); testResetTouchesOnlyItsOwnRingOnADualRingKnob(); testInnerResetLandsOnTheExactLinearNeutral(); testResetLandsOnTheStoredDefaultOfEachControl(); testTimeConstantsAlwaysReadInMilliseconds(); if (g_fail) { std::printf("%d FAILURE(S)\n", g_fail); return 1; } std::printf("deck_values tests passed\n"); return 0; }