instrument: one VELOCITY deck for all three velocity curves, bipolar and off by default for pitch and filter

Payload v12 appends the new velocity->pitch curve and folds the retired filter velAmount into its now-bipolar curve, so pre-v12 projects reopen sounding identical. Preview button takes a drawn play triangle.
This commit is contained in:
2026-07-31 19:15:17 -04:00
parent 4fecb58c0a
commit 9d38f87a2d
37 changed files with 1020 additions and 320 deletions
+78 -13
View File
@@ -1,7 +1,9 @@
// Standalone tests for reasampler::instrument::ui::deck_groups — no VST3, no REAPER, no
// framework. knob_deck's own tests pin how a descriptor list LAYS OUT; these pin WHICH
// descriptors the Sample face carries: the signal-flow group order (pitch -> filter -> amp),
// the Filter group's contents, the wrapped deck height at the editor's floor width and its fit
// the Filter group's contents, the VELOCITY group's exclusive ownership of the three curve
// cells and its placement immediately left of VOICE, the wrapped deck height at the editor's
// floor width and its fit
// inside the floor window, the hit-test reaching the new filter controls, the bipolar knob
// law's inverse pair, the commit-tier routing — which controls are live, and which drags take
// the live tier — and the overlay-selection state machine (exclusivity, the none resting state,
@@ -48,20 +50,77 @@ static void testDeckReadsPitchThenFilterThenAmpLeftToRight() {
CHECK(penv < filt);
CHECK(filt < fenv);
CHECK(fenv < amp);
// The two instance-wide groups stay at the end.
CHECK(amp < indexOfGroup(g, kGroupVoice));
// VELOCITY then the two instance-wide groups at the end. Velocity sits IMMEDIATELY
// left of VOICE — MASTER is reserved for post-voice-mixer concerns, so the curves
// must not drift into it.
const int vel = indexOfGroup(g, kGroupVelocity);
CHECK(amp < vel);
CHECK(vel + 1 == indexOfGroup(g, kGroupVoice));
CHECK(indexOfGroup(g, kGroupVoice) < indexOfGroup(g, kGroupMaster));
}
}
static void testFilterGroupCarriesItsFiveToneControlsPlusModulation() {
// The three velocity curves live together in VELOCITY and nowhere else: no other group may
// carry a curve cell, or the "one home" the group exists for is not one.
static void testVelocityGroupOwnsTheThreeCurvesExclusively() {
for (PlayMode mode : {PlayMode::Gate, PlayMode::Trigger}) {
const std::vector<DeckGroupDesc> g = sampleDeckGroups(mode);
const DeckGroupDesc& v =
g[static_cast<std::size_t>(indexOfGroup(g, kGroupVelocity))];
const std::vector<int> expected = {cell(DeckParam::kAmpVelCurve),
cell(DeckParam::kPitchVelCurve),
cell(DeckParam::kFilterVelCurve)};
CHECK(v.cellIds == expected);
CHECK(v.captionToggle.id == -1 && v.rowToggle.id == -1 && v.captionRadio.id == -1);
for (const DeckGroupDesc& d : g) {
if (d.id == kGroupVelocity) continue;
for (int id : d.cellIds) CHECK(curveTargetFor(id) == CurveTarget::kNone);
CHECK(curveTargetFor(d.captionToggle.id) == CurveTarget::kNone);
CHECK(curveTargetFor(d.rowToggle.id) == CurveTarget::kNone);
}
}
}
// Each curve cell names its OWN destination, and an ordinary knob names none — the predicate
// the shell uses to tell a popup opener from a dial.
static void testCurveTargetNamesEachCellsOwnDestination() {
CHECK(curveTargetFor(cell(DeckParam::kAmpVelCurve)) == CurveTarget::kAmp);
CHECK(curveTargetFor(cell(DeckParam::kPitchVelCurve)) == CurveTarget::kPitch);
CHECK(curveTargetFor(cell(DeckParam::kFilterVelCurve)) == CurveTarget::kFilter);
CHECK(curveTargetFor(cell(DeckParam::kFilterCutoff)) == CurveTarget::kNone);
CHECK(curveTargetFor(cell(DeckParam::kMasterGain)) == CurveTarget::kNone);
CHECK(curveTargetFor(-1) == CurveTarget::kNone); // a blank reserved cell
CHECK(curveTargetFor(9999) == CurveTarget::kNone); // out of the id space
}
// The cells hit-test inside their own group, from the centre of each cell — the deck grammar
// treats them as knob cells, so the popup routing rides an ordinary Knob hit.
static void testVelocityCellsHitTestWithinTheirGroup() {
const std::vector<DeckGroupDesc> g = sampleDeckGroups(PlayMode::Gate);
const DeckLayout dl = layoutDeck(g, kPad, 40, kAvailAtMinWidth);
const DeckGroupLayout& v =
dl.groups[static_cast<std::size_t>(indexOfGroup(g, kGroupVelocity))];
CHECK(v.cells.size() == 3);
const CurveTarget want[] = {CurveTarget::kAmp, CurveTarget::kPitch, CurveTarget::kFilter};
for (std::size_t i = 0; i < v.cells.size(); ++i) {
const DeckCellLayout& c = v.cells[i];
const DeckHit hit = hitTestDeck(dl, c.cell.x + c.cell.width / 2,
c.cell.y + c.cell.height / 2);
CHECK(hit.kind == DeckHitKind::Knob);
CHECK(hit.id == c.id);
CHECK(curveTargetFor(hit.id) == want[i]);
// Inside its own group box, and the cell the hit resolved is this one.
CHECK(c.cell.x >= v.box.x && c.cell.right() <= v.box.right());
}
}
static void testFilterGroupCarriesItsToneControlsPlusModulation() {
const std::vector<DeckGroupDesc>& g = sampleDeckGroups(PlayMode::Gate);
const DeckGroupDesc& f = g[static_cast<std::size_t>(indexOfGroup(g, kGroupFilter))];
const std::vector<int> expected = {
cell(DeckParam::kFilterMorph), cell(DeckParam::kFilterCutoff),
cell(DeckParam::kFilterQ), cell(DeckParam::kFilterDrive),
cell(DeckParam::kFilterModAmt), cell(DeckParam::kFilterVel),
cell(DeckParam::kFilterKeyTrack)};
cell(DeckParam::kFilterModAmt), cell(DeckParam::kFilterKeyTrack)};
CHECK(f.cellIds == expected);
// Off by default is a state question, but reachability is a layout one: the enable
// toggle is in the caption row and the morph law in the knob row.
@@ -177,10 +236,12 @@ static void testAmpGroupWidthSurvivesAGateTriggerFlip() {
static void testWrappedDeckHeightAtTheEditorFloorWidth() {
const std::vector<DeckGroupDesc> g = sampleDeckGroups(PlayMode::Gate);
// At the floor (== default) 840 the deck takes two rows: PITCH + PITCH ENV + FILTER fill
// the first, the remaining four fit the second.
CHECK(deckRowCount(g, kAvailAtMinWidth) == 2);
CHECK(deckHeight(g, kAvailAtMinWidth) == 2 * kDeckGroupH + kDeckRowGap);
// At the floor (== default) 840 the deck takes three rows: PITCH + PITCH ENV + FILTER fill
// the first, FILTER ENV + AMP + VELOCITY the second, VOICE + MASTER the third. The eight
// groups total more than two rows can hold at this width — the VELOCITY group's three
// cells are ~150 px more than the FILTER group gave back when its velocity depth retired.
CHECK(deckRowCount(g, kAvailAtMinWidth) == 3);
CHECK(deckHeight(g, kAvailAtMinWidth) == 3 * kDeckGroupH + 2 * kDeckRowGap);
// Whole groups only, never split: every group's box lies inside the available width or is
// the first of its row.
@@ -224,7 +285,7 @@ static void testHitTestResolvesTheNewFilterControls() {
CHECK(hit.kind == DeckHitKind::Knob);
CHECK(hit.id == c.id);
}
CHECK(f.cells.size() == 7);
CHECK(f.cells.size() == 6);
CHECK(f.cells[1].id == cell(DeckParam::kFilterCutoff));
// The enable toggle's two segments and the morph-law row toggle's two.
@@ -298,7 +359,8 @@ static void testEveryDeckControlIsClassifiedLiveOrReloading() {
// is excluded.
const DeckParam reloads[] = {
DeckParam::kPlayMode, DeckParam::kPitchEngine, DeckParam::kPitchEnvEnable,
DeckParam::kFilterEnable, DeckParam::kFilterLaw, DeckParam::kFilterVel,
DeckParam::kFilterEnable, DeckParam::kFilterLaw,
DeckParam::kAmpVelCurve, DeckParam::kPitchVelCurve, DeckParam::kFilterVelCurve,
DeckParam::kKeyTrack, DeckParam::kTrigLength,
DeckParam::kAmpEnvSelect, DeckParam::kPitchEnvSelect, DeckParam::kFilterEnvSelect,
DeckParam::kVoiceCount, DeckParam::kVoiceMode,
@@ -405,7 +467,10 @@ int main() {
testEveryDeckControlIsClassifiedLiveOrReloading();
testOnlyALiveControlsDragTakesTheLiveTier();
testDeckReadsPitchThenFilterThenAmpLeftToRight();
testFilterGroupCarriesItsFiveToneControlsPlusModulation();
testVelocityGroupOwnsTheThreeCurvesExclusively();
testCurveTargetNamesEachCellsOwnDestination();
testVelocityCellsHitTestWithinTheirGroup();
testFilterGroupCarriesItsToneControlsPlusModulation();
testOnlyTheThreeEnvelopeDecksCarryARadio();
testGateAndTriggerFacesCarryTheirOwnShapes();
testOnlySlopedStageKnobsCarryAnInnerCurveDial();