ca464397b2
The waveform column's vertical extents move to pure component_geometry so the shared primitive stops being untested; PlaySeconds hoists into a header-only play_seconds target.
190 lines
8.4 KiB
C++
190 lines
8.4 KiB
C++
// 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 <cstdio>
|
|
#include <cstring>
|
|
#include <string>
|
|
|
|
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;
|
|
}
|