feat: run the per-voice filter between the pitch and amp stages, with its own deck

Params ride the one parameter set; payload v8 -> v9, off by default.
Deck composition moves to a pure deck_groups module in pitch -> filter -> amp order.
This commit is contained in:
2026-07-30 15:26:14 -04:00
parent c9c708a338
commit 67215509cb
25 changed files with 1354 additions and 191 deletions
+182
View File
@@ -0,0 +1,182 @@
// 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 two pinned widths, the
// hit-test reaching the new filter controls, and the bipolar knob law's inverse pair.
#include "../src/core/instrument/ui/deck_groups.h"
#include <cmath>
#include <cstdio>
#include <vector>
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)
// The editor's two pinned client widths (checkSizeConstraint's 560 floor, the 840 default),
// less the band allocator's kPad inset on each side.
static constexpr int kAvailAtMinWidth = 560 - 16;
static constexpr int kAvailAtDefaultWidth = 840 - 16;
static int indexOfGroup(const std::vector<DeckGroupDesc>& g, int id) {
for (std::size_t i = 0; i < g.size(); ++i) {
if (g[i].id == id) return static_cast<int>(i);
}
return -1;
}
static int cell(DeckParam p) { return static_cast<int>(p); }
static void testDeckReadsPitchThenFilterThenAmpLeftToRight() {
for (PlayMode mode : {PlayMode::Gate, PlayMode::Trigger}) {
const std::vector<DeckGroupDesc> g = sampleDeckGroups(mode);
const int pitch = indexOfGroup(g, kGroupPitch);
const int penv = indexOfGroup(g, kGroupPitchEnv);
const int filt = indexOfGroup(g, kGroupFilter);
const int fenv = indexOfGroup(g, kGroupFilterEnv);
const int amp = indexOfGroup(g, kGroupAmpEnv);
CHECK(pitch >= 0 && penv >= 0 && filt >= 0 && fenv >= 0 && amp >= 0);
// The signal flow, left to right. Each envelope group trails its own stage.
CHECK(pitch < penv);
CHECK(penv < filt);
CHECK(filt < fenv);
CHECK(fenv < amp);
// The two instance-wide groups stay at the end.
CHECK(amp < indexOfGroup(g, kGroupVoice));
CHECK(indexOfGroup(g, kGroupVoice) < indexOfGroup(g, kGroupMaster));
}
}
static void testFilterGroupCarriesItsFiveToneControlsPlusModulation() {
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)};
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.
CHECK(f.captionToggle.id == cell(DeckParam::kFilterEnable));
CHECK(f.rowToggle.id == cell(DeckParam::kFilterLaw));
const DeckGroupDesc& fe = g[static_cast<std::size_t>(indexOfGroup(g, kGroupFilterEnv))];
const std::vector<int> env = {
cell(DeckParam::kFilterEnvAttack), cell(DeckParam::kFilterEnvHold),
cell(DeckParam::kFilterEnvDecay), cell(DeckParam::kFilterEnvSustain),
cell(DeckParam::kFilterEnvRelease)};
CHECK(fe.cellIds == env);
// The filter envelope has no enable of its own — the FILTER group's toggle governs both.
CHECK(fe.captionToggle.id == -1);
CHECK(fe.rowToggle.id == -1);
}
static void testAmpGroupWidthSurvivesAGateTriggerFlip() {
// The reserved blanks are what stop a mode flip reflowing the groups beside AMP.
const std::vector<DeckGroupDesc> gate = sampleDeckGroups(PlayMode::Gate);
const std::vector<DeckGroupDesc> trig = sampleDeckGroups(PlayMode::Trigger);
const DeckGroupDesc& a = gate[static_cast<std::size_t>(indexOfGroup(gate, kGroupAmpEnv))];
const DeckGroupDesc& b = trig[static_cast<std::size_t>(indexOfGroup(trig, kGroupAmpEnv))];
CHECK(deckGroupWidth(a) == deckGroupWidth(b));
CHECK(a.cellIds.size() == b.cellIds.size());
CHECK(b.cellIds[3] == -1 && b.cellIds[4] == -1);
// Every other group is mode-independent, so the whole deck's height is too.
CHECK(deckHeight(gate, kAvailAtDefaultWidth) == deckHeight(trig, kAvailAtDefaultWidth));
CHECK(deckHeight(gate, kAvailAtMinWidth) == deckHeight(trig, kAvailAtMinWidth));
}
static void testWrappedDeckHeightAtThePinnedEditorWidths() {
const std::vector<DeckGroupDesc> g = sampleDeckGroups(PlayMode::Gate);
// At the default 840 the deck takes two rows: PITCH + PITCH ENV + FILTER fill the first,
// the remaining four fit the second.
CHECK(deckRowCount(g, kAvailAtDefaultWidth) == 2);
CHECK(deckHeight(g, kAvailAtDefaultWidth) == 2 * kDeckGroupH + kDeckRowGap);
// At the 560 floor it takes four; FILTER is wider than the row on its own.
CHECK(deckRowCount(g, kAvailAtMinWidth) == 4);
CHECK(deckHeight(g, kAvailAtMinWidth) == 4 * kDeckGroupH + 3 * kDeckRowGap);
// Whole groups only, never split: every group's box lies inside the available width or is
// the first of its row.
const DeckLayout dl = layoutDeck(g, 8, 0, kAvailAtDefaultWidth);
CHECK(dl.groups.size() == g.size());
for (const DeckGroupLayout& gl : dl.groups) {
CHECK(gl.box.x >= 8);
CHECK(gl.box.height == kDeckGroupH);
}
}
static void testHitTestResolvesTheNewFilterControls() {
const std::vector<DeckGroupDesc> g = sampleDeckGroups(PlayMode::Gate);
const DeckLayout dl = layoutDeck(g, 8, 40, kAvailAtDefaultWidth);
const DeckGroupLayout& f =
dl.groups[static_cast<std::size_t>(indexOfGroup(g, kGroupFilter))];
// Every knob cell resolves to its own id, from the centre of its cell.
for (const DeckCellLayout& c : f.cells) {
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(f.cells.size() == 7);
CHECK(f.cells[1].id == cell(DeckParam::kFilterCutoff));
// The enable toggle's two segments and the morph-law row toggle's two.
const DeckHit off = hitTestDeck(dl, f.captionToggle.seg0.x + 2,
f.captionToggle.seg0.y + 2);
CHECK(off.kind == DeckHitKind::CaptionToggle);
CHECK(off.id == cell(DeckParam::kFilterEnable) && off.segment == 0);
const DeckHit on = hitTestDeck(dl, f.captionToggle.seg1.x + 2,
f.captionToggle.seg1.y + 2);
CHECK(on.id == cell(DeckParam::kFilterEnable) && on.segment == 1);
const DeckHit band = hitTestDeck(dl, f.rowToggle.seg0.x + 2, f.rowToggle.seg0.y + 2);
CHECK(band.kind == DeckHitKind::RowToggle);
CHECK(band.id == cell(DeckParam::kFilterLaw) && band.segment == 0);
const DeckHit notch = hitTestDeck(dl, f.rowToggle.seg1.x + 2, f.rowToggle.seg1.y + 2);
CHECK(notch.id == cell(DeckParam::kFilterLaw) && notch.segment == 1);
// The filter-envelope knobs resolve too, and are distinct ids from the amp's.
const DeckGroupLayout& fe =
dl.groups[static_cast<std::size_t>(indexOfGroup(g, kGroupFilterEnv))];
const DeckHit attack = hitTestDeck(dl, fe.cells[0].cell.x + 4, fe.cells[0].cell.y + 4);
CHECK(attack.kind == DeckHitKind::Knob);
CHECK(attack.id == cell(DeckParam::kFilterEnvAttack));
CHECK(attack.id != cell(DeckParam::kAttack));
}
static void testBipolarKnobLawRoundTripsAndIsExactAtCentre() {
// Centre is EXACT in both directions: a knob parked at 0.5 stores 0, and 0 reads back
// 0.5 — no residual modulation from a rounding hair.
CHECK(deckBipolarFromNorm(0.5) == 0.0);
CHECK(deckNormFromBipolar(0.0) == 0.5);
CHECK(deckBipolarFromNorm(0.0) == -1.0);
CHECK(deckBipolarFromNorm(1.0) == 1.0);
for (int i = 0; i <= 200; ++i) {
const double norm = static_cast<double>(i) / 200.0;
CHECK(std::fabs(deckNormFromBipolar(deckBipolarFromNorm(norm)) - norm) < 1e-12);
const double value = -1.0 + static_cast<double>(i) / 100.0;
CHECK(std::fabs(deckBipolarFromNorm(deckNormFromBipolar(value)) - value) < 1e-12);
}
// Out of range clamps rather than extrapolating.
CHECK(deckBipolarFromNorm(-3.0) == -1.0);
CHECK(deckBipolarFromNorm(3.0) == 1.0);
CHECK(deckNormFromBipolar(-3.0) == 0.0);
CHECK(deckNormFromBipolar(3.0) == 1.0);
}
int main() {
testDeckReadsPitchThenFilterThenAmpLeftToRight();
testFilterGroupCarriesItsFiveToneControlsPlusModulation();
testAmpGroupWidthSurvivesAGateTriggerFlip();
testWrappedDeckHeightAtThePinnedEditorWidths();
testHitTestResolvesTheNewFilterControls();
testBipolarKnobLawRoundTripsAndIsExactAtCentre();
if (g_fail == 0) std::printf("deck_groups: all tests passed\n");
return g_fail == 0 ? 0 : 1;
}