feat: legible ReaSampler 9000 editor — bigger knobs, ms time constants, per-ring double-click reset, and an antialiased draw pass

This commit is contained in:
2026-08-01 09:16:30 -04:00
parent 213ecfafe6
commit 7f74b11dce
27 changed files with 859 additions and 285 deletions
+175
View File
@@ -0,0 +1,175 @@
// 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);
setDeckParam(DeckParam::kFilterCutoff, p, 0.5, 0);
CHECK(deckParamNorm(DeckParam::kFilterCutoff, p) == 0.5);
// 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 rather than from a second table.
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);
resetDeckParam(DeckParam::kSustain, p);
resetDeckParam(DeckParam::kTrigLength, p);
resetDeckParam(DeckParam::kFilterKeyTrack, p);
resetDeckParam(DeckParam::kPitchEnvDepth, 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);
}
// 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;
}