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:
@@ -238,11 +238,11 @@ static void testAmpGroupWidthSurvivesAGateTriggerFlip() {
|
||||
|
||||
static void testWrappedDeckHeightAtTheEditorFloorWidth() {
|
||||
const std::vector<DeckGroupDesc> g = sampleDeckGroups(PlayMode::Gate);
|
||||
// At the floor (== default) 840 the deck takes three rows: PITCH + PITCH ENV + FILTER fill
|
||||
// the first (818 of the 824 available — six px of headroom, so one more FILTER cell would
|
||||
// wrap the group and reflow everything under it), FILTER ENV + AMP + VELOCITY the second,
|
||||
// VOICE + MASTER the third. Two rows cannot hold the eight groups in ANY order at this
|
||||
// width: 1666 px of group plus 72 px of gaps against a 1648 px two-row capacity.
|
||||
// At the floor (== default) 980 the deck takes three rows: PITCH + PITCH ENV + FILTER fill
|
||||
// the first (950 of the 964 available — fourteen px of headroom, so one more FILTER cell
|
||||
// would wrap the group and reflow everything under it), FILTER ENV + AMP + VELOCITY the
|
||||
// second, VOICE + MASTER the third. Two rows cannot hold the eight groups in ANY order at
|
||||
// this width: 1978 px of group plus 72 px of gaps against a 1928 px two-row capacity.
|
||||
CHECK(deckRowCount(g, kAvailAtMinWidth) == 3);
|
||||
CHECK(deckHeight(g, kAvailAtMinWidth) == 3 * kDeckGroupH + 2 * kDeckRowGap);
|
||||
|
||||
@@ -258,7 +258,7 @@ static void testWrappedDeckHeightAtTheEditorFloorWidth() {
|
||||
|
||||
// The guard the raised floor exists to provide: at the smallest window the host can produce,
|
||||
// the deck band still lands inside the client area AND the waveform still gets its two-lane
|
||||
// floor. Growing the deck past what 620 px can hold fails HERE instead of silently pushing
|
||||
// floor. Growing the deck past what the floor height can hold fails HERE instead of silently pushing
|
||||
// FILTER ENV / AMP / VOICE / MASTER off-screen, where there is no scroll to reach them.
|
||||
static void testDeckFitsInsideTheEnforcedMinimumWindow() {
|
||||
for (PlayMode mode : {PlayMode::Gate, PlayMode::Trigger}) {
|
||||
@@ -574,9 +574,9 @@ static void testNoFaceLeavesSlackWhereItsDroppedControlsWere() {
|
||||
static void testGateModeWidthsAndRowAssignmentAreUnchanged() {
|
||||
const std::vector<DeckGroupDesc> g = sampleDeckGroups(PlayMode::Gate);
|
||||
const struct { int id; int width; int row; } want[] = {
|
||||
{kGroupPitch, 150, 0}, {kGroupPitchEnv, 204, 0}, {kGroupFilter, 440, 0},
|
||||
{kGroupFilterEnv, 252, 1}, {kGroupAmpEnv, 252, 1}, {kGroupVelocity, 156, 1},
|
||||
{kGroupVoice, 152, 2}, {kGroupMaster, 60, 2},
|
||||
{kGroupPitch, 150, 0}, {kGroupPitchEnv, 252, 0}, {kGroupFilter, 524, 0},
|
||||
{kGroupFilterEnv, 312, 1}, {kGroupAmpEnv, 312, 1}, {kGroupVelocity, 192, 1},
|
||||
{kGroupVoice, 164, 2}, {kGroupMaster, 72, 2},
|
||||
};
|
||||
CHECK(g.size() == sizeof(want) / sizeof(want[0]));
|
||||
const DeckLayout dl = layoutDeck(g, kPad, 0, kAvailAtMinWidth);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
+63
-11
@@ -9,6 +9,8 @@
|
||||
// always places; deckHeight consistency with deckRowCount.
|
||||
// * hit-test — knob cell hit (whole cell), toggle segment 0/1 boundaries, fence padding
|
||||
// misses, outside-deck misses.
|
||||
// * knob-FACE hit-test — the reset resolve against the drawn circles: inner disc, outer ring,
|
||||
// both exclusive boundaries, and the points where it deliberately disagrees with the cell.
|
||||
|
||||
#include "../src/core/instrument/ui/knob_deck.h"
|
||||
|
||||
@@ -183,7 +185,7 @@ static void testHitTest() {
|
||||
// not cover is smaller than one pixel per cell.
|
||||
static void testReservedCellWidthGoesToTheCellsPresent() {
|
||||
const DeckGroupDesc full{0, 78, {}, {100, 44}, {}, {20, 21, 22, 23, 24}, {}};
|
||||
// Three, four, and a lone cell against the same five-slot reserve — 240/3, 240/4, 240/1.
|
||||
// Three, four, and a lone cell against the same five-slot reserve.
|
||||
const std::vector<std::vector<int>> faces = {
|
||||
{20, 21, 22, -1, -1}, {20, 21, 22, 23, -1}, {20, -1, -1, -1, -1}};
|
||||
for (const std::vector<int>& ids : faces) {
|
||||
@@ -202,7 +204,13 @@ static void testReservedCellWidthGoesToTheCellsPresent() {
|
||||
const DeckCellLayout& c = lay.cells[static_cast<std::size_t>(i)];
|
||||
CHECK(c.cell.width == lay.cells[0].cell.width); // uniform
|
||||
CHECK(c.knob.width == kDeckKnobSize); // the dial itself is fixed
|
||||
CHECK(c.knob.x - c.cell.x == c.cell.right() - c.knob.right());
|
||||
// Centred as exactly as integers allow: a cell whose spare width is odd cannot
|
||||
// split it evenly, and the layout's integer division gives the odd pixel to the
|
||||
// RIGHT margin. Pinned as a directional identity rather than a tolerance, so a
|
||||
// future off-by-one on the other side would still fail here.
|
||||
const int leftGap = c.knob.x - c.cell.x;
|
||||
const int rightGap = c.cell.right() - c.knob.right();
|
||||
CHECK(rightGap - leftGap == (c.cell.width - kDeckKnobSize) % 2);
|
||||
if (i > 0) CHECK(c.cell.x == lay.cells[static_cast<std::size_t>(i - 1)].cell.right());
|
||||
}
|
||||
// Uncovered run is the indivisible residue only, split evenly at the two ends.
|
||||
@@ -222,21 +230,21 @@ static void testReservedCellWidthGoesToTheCellsPresent() {
|
||||
layoutDeck(b, 0, 0, 824).groups[0].rowToggle.seg0);
|
||||
}
|
||||
|
||||
// The three faces above (240/3, 240/4, 240/1) all divide their run evenly, so none of them
|
||||
// actually exercises "residue in symmetric end margins". An 8-slot reserve with 5 present
|
||||
// (384/5 = 76 r4) does: residue 4 is the smallest case that can tell a symmetric split (2/2)
|
||||
// apart from a trailing-only one (0/4) — a residue of 1 (0/1 vs 1/0... i.e. 0/1) can't, since
|
||||
// leadPad = residue/2 rounds to 0 either way, which is exactly why this seam's earlier test
|
||||
// passed without pinning the rule it was named for.
|
||||
// The three faces above all divide their run evenly, so none of them actually exercises
|
||||
// "residue in symmetric end margins". An 8-slot reserve with 7 present (480/7 = 68 r4) does:
|
||||
// residue 4 is the smallest case that can tell a symmetric split (2/2) apart from a
|
||||
// trailing-only one (0/4) — a residue of 1 can't, since leadPad = residue/2 rounds to 0 either
|
||||
// way, which is exactly why this seam's earlier test passed without pinning the rule it was
|
||||
// named for.
|
||||
static void testIndivisibleResidueSplitsSymmetricallyAcrossBothEnds() {
|
||||
const DeckGroupDesc g{0, 78, {}, {100, 44}, {}, {20, 21, 22, 23, 24, -1, -1, -1}, {}};
|
||||
const DeckGroupDesc g{0, 78, {}, {100, 44}, {}, {20, 21, 22, 23, 24, 25, 26, -1}, {}};
|
||||
std::vector<DeckGroupDesc> gs{g};
|
||||
const DeckLayout dl = layoutDeck(gs, 0, 0, 824);
|
||||
const DeckGroupLayout& lay = dl.groups[0];
|
||||
CHECK(lay.cells.size() == 5);
|
||||
CHECK(lay.cells.size() == 7);
|
||||
|
||||
const int run = 8 * kDeckCellW;
|
||||
const int present = 5;
|
||||
const int present = 7;
|
||||
const int cellW = run / present; // 76: the same integer division the layout uses
|
||||
const int expectedResidue = run - cellW * present; // 4
|
||||
CHECK(expectedResidue == 4);
|
||||
@@ -331,6 +339,49 @@ static void testCaptionToggle2() {
|
||||
CHECK(h.kind == DeckHitKind::CaptionToggle && h.id == 302 && h.segment == 1);
|
||||
}
|
||||
|
||||
// The double-click RESET resolve. Unlike hitTestDeck's whole-cell grab, this one answers the
|
||||
// drawn circles: inner disc -> inner target, outer ring -> outer target, anything off the dial
|
||||
// (the label band, the cell margin, outside the deck) -> neither. Both boundaries are exclusive.
|
||||
static void testKnobFaceResolvesInnerRingOuterRingAndMisses() {
|
||||
const std::vector<DeckGroupDesc> g = shellLikeDeck();
|
||||
const DeckLayout dl = layoutDeck(g, 0, 0, 900);
|
||||
const DeckCellLayout& c = dl.groups[0].cells[0];
|
||||
const int cx = c.knob.x + c.knob.width / 2;
|
||||
const int cy = c.knob.y + c.knob.height / 2;
|
||||
const int rOuter = c.knob.width / 2;
|
||||
const int rInner = c.inner.width / 2;
|
||||
CHECK(rInner > 0 && rInner < rOuter);
|
||||
|
||||
// Dead centre is the inner target; just inside the inner radius still is.
|
||||
DeckFaceHit h = hitTestKnobFace(dl, cx, cy);
|
||||
CHECK(h.id == c.id && h.inner);
|
||||
h = hitTestKnobFace(dl, cx + rInner - 1, cy);
|
||||
CHECK(h.id == c.id && h.inner);
|
||||
// EXACTLY on the inner radius is the outer ring — the boundary belongs to neither disc.
|
||||
h = hitTestKnobFace(dl, cx + rInner, cy);
|
||||
CHECK(h.id == c.id && !h.inner);
|
||||
// Just inside the rim is still the outer ring...
|
||||
h = hitTestKnobFace(dl, cx + rOuter - 1, cy);
|
||||
CHECK(h.id == c.id && !h.inner);
|
||||
// ...and EXACTLY on the rim is a miss, by the same exclusive rule.
|
||||
h = hitTestKnobFace(dl, cx + rOuter, cy);
|
||||
CHECK(h.id == -1 && !h.inner);
|
||||
|
||||
// The cell corner is inside the CELL (hitTestDeck resolves it as a grab) but outside the
|
||||
// circle — the two resolves deliberately disagree there.
|
||||
CHECK(hitTestDeck(dl, c.cell.x + 1, c.cell.y + 1).kind == DeckHitKind::Knob);
|
||||
CHECK(hitTestKnobFace(dl, c.cell.x + 1, c.cell.y + 1).id == -1);
|
||||
// The label band under the knob: a grab anchor, never a reset target.
|
||||
CHECK(hitTestDeck(dl, c.label.x + 2, c.label.y + 2).kind == DeckHitKind::Knob);
|
||||
CHECK(hitTestKnobFace(dl, c.label.x + 2, c.label.y + 2).id == -1);
|
||||
// Off the deck entirely.
|
||||
CHECK(hitTestKnobFace(dl, -50, -50).id == -1);
|
||||
// A diagonal at 45 degrees inside the rim: proves the resolve is radial, not the inscribed
|
||||
// square a rect test would accept — this point is inside the knob RECT but outside the disc.
|
||||
const int diag = static_cast<int>(rOuter * 0.75) + 1; // dist ~ 1.06 * rOuter
|
||||
CHECK(hitTestKnobFace(dl, cx + diag, cy + diag).id == -1);
|
||||
}
|
||||
|
||||
static void testEmptyDeck() {
|
||||
const std::vector<DeckGroupDesc> none;
|
||||
CHECK(deckRowCount(none, 800) == 0);
|
||||
@@ -349,6 +400,7 @@ int main() {
|
||||
testIndivisibleResidueSplitsSymmetricallyAcrossBothEnds();
|
||||
testCaptionRadioGeometryAndHit();
|
||||
testInnerDialHit();
|
||||
testKnobFaceResolvesInnerRingOuterRingAndMisses();
|
||||
testCaptionToggle2();
|
||||
testEmptyDeck();
|
||||
if (g_fail) {
|
||||
|
||||
@@ -21,9 +21,9 @@ 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 constexpr int kKnob = 26; // stands in for knob_deck's kDeckKnobSize
|
||||
static constexpr int kKnob = 40; // stands in for knob_deck's kDeckKnobSize
|
||||
|
||||
static Rect chromeBand(int w = 840, int h = 620) {
|
||||
static Rect chromeBand(int w = kEditorMinWidth, int h = kEditorMinHeight) {
|
||||
return computeSampleBands(w, h, 120).chrome;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user