filter: sweep the corner continuously; raise the editor floor to 840x620
Cutoff-only re-solve (15.5 vs 56.9 ns/frame) makes the unquantized sweep affordable, replacing the 2048-step mod quantizer. Live-compute parameters remain blocked on a shell-architecture ruling.
This commit is contained in:
+36
-20
@@ -1,10 +1,12 @@
|
||||
// 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.
|
||||
// the Filter group's contents, 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, and the bipolar knob
|
||||
// law's inverse pair.
|
||||
|
||||
#include "../src/core/instrument/ui/deck_groups.h"
|
||||
#include "../src/core/instrument/ui/sample_bands.h"
|
||||
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
@@ -17,10 +19,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)
|
||||
|
||||
// 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;
|
||||
// The editor's floor width, which is also its default (checkSizeConstraint clamps to it), less
|
||||
// the band allocator's kPad inset on each side.
|
||||
static constexpr int kAvailAtMinWidth = kEditorMinWidth - 2 * kPad;
|
||||
|
||||
static int indexOfGroup(const std::vector<DeckGroupDesc>& g, int id) {
|
||||
for (std::size_t i = 0; i < g.size(); ++i) {
|
||||
@@ -86,34 +87,48 @@ static void testAmpGroupWidthSurvivesAGateTriggerFlip() {
|
||||
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() {
|
||||
static void testWrappedDeckHeightAtTheEditorFloorWidth() {
|
||||
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 (440px) fits the row alone, but not alongside
|
||||
// PITCH + PITCH ENV (318 + 12 + 440 = 770 > 544), so it wraps to its own row.
|
||||
CHECK(deckRowCount(g, kAvailAtMinWidth) == 4);
|
||||
CHECK(deckHeight(g, kAvailAtMinWidth) == 4 * kDeckGroupH + 3 * kDeckRowGap);
|
||||
// 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);
|
||||
|
||||
// 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);
|
||||
const DeckLayout dl = layoutDeck(g, kPad, 0, kAvailAtMinWidth);
|
||||
CHECK(dl.groups.size() == g.size());
|
||||
for (const DeckGroupLayout& gl : dl.groups) {
|
||||
CHECK(gl.box.x >= 8);
|
||||
CHECK(gl.box.x >= kPad);
|
||||
CHECK(gl.box.height == kDeckGroupH);
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
// 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}) {
|
||||
const std::vector<DeckGroupDesc> g = sampleDeckGroups(mode);
|
||||
const int h = deckHeight(g, kAvailAtMinWidth);
|
||||
const SampleBands b = computeSampleBands(kEditorMinWidth, kEditorMinHeight, h);
|
||||
CHECK(b.decks.height == h);
|
||||
// Bottom-anchored INSIDE the pad is the whole assertion: the degrade path pushes the
|
||||
// deck down until the waveform hits its floor, so any deck too tall to fit stops
|
||||
// landing on this exact line. A `<= kEditorMinHeight` bound would not catch it — the
|
||||
// degrade can still leave the deck ending at the window edge.
|
||||
CHECK(b.decks.bottom() == kEditorMinHeight - kPad);
|
||||
CHECK(b.waveform.height >= kWaveformMinHeight);
|
||||
}
|
||||
}
|
||||
|
||||
static void testHitTestResolvesTheNewFilterControls() {
|
||||
const std::vector<DeckGroupDesc> g = sampleDeckGroups(PlayMode::Gate);
|
||||
const DeckLayout dl = layoutDeck(g, 8, 40, kAvailAtDefaultWidth);
|
||||
const DeckLayout dl = layoutDeck(g, kPad, 40, kAvailAtMinWidth);
|
||||
const DeckGroupLayout& f =
|
||||
dl.groups[static_cast<std::size_t>(indexOfGroup(g, kGroupFilter))];
|
||||
|
||||
@@ -175,7 +190,8 @@ int main() {
|
||||
testDeckReadsPitchThenFilterThenAmpLeftToRight();
|
||||
testFilterGroupCarriesItsFiveToneControlsPlusModulation();
|
||||
testAmpGroupWidthSurvivesAGateTriggerFlip();
|
||||
testWrappedDeckHeightAtThePinnedEditorWidths();
|
||||
testWrappedDeckHeightAtTheEditorFloorWidth();
|
||||
testDeckFitsInsideTheEnforcedMinimumWindow();
|
||||
testHitTestResolvesTheNewFilterControls();
|
||||
testBipolarKnobLawRoundTripsAndIsExactAtCentre();
|
||||
if (g_fail == 0) std::printf("deck_groups: all tests passed\n");
|
||||
|
||||
@@ -4,9 +4,8 @@
|
||||
// * group width — caption row vs knob row max + padding; row-toggle and caption-toggle widths.
|
||||
// * layout — caption toggle right-anchored IN the caption row; cells fixed 48x58 left-to-right
|
||||
// inside the box; knob square centered; label band beneath; row toggle after the cells.
|
||||
// * wrap — deterministic whole-group wrap at a narrowing width (the r11 "PITCH ENV onto row
|
||||
// two at the 560 floor" behavior); the first group of a row always places; deckHeight
|
||||
// consistency with deckRowCount.
|
||||
// * wrap — deterministic whole-group wrap at a narrowing width; the first group of a row
|
||||
// always places; deckHeight consistency with deckRowCount.
|
||||
// * hit-test — knob cell hit (whole cell), toggle segment 0/1 boundaries, blank (-1) cells
|
||||
// and fence padding miss, outside-deck miss.
|
||||
|
||||
@@ -52,8 +51,9 @@ static void testGroupWidth() {
|
||||
}
|
||||
|
||||
static void testWrapAtNarrowWidthIsDeterministic() {
|
||||
// At the 560x460 checkSizeConstraint floor (544 available) the deck wraps to TWO rows,
|
||||
// whole trailing groups only.
|
||||
// A width that forces this synthetic deck to wrap: TWO rows, whole trailing groups only.
|
||||
// Deliberately narrower than the shipped editor floor — this pins the wrap MECHANISM, not
|
||||
// the shipped deck's row count (that is deck_groups' own test).
|
||||
const auto deck = shellLikeDeck();
|
||||
CHECK(deckRowCount(deck, 544) == 2);
|
||||
CHECK(deckHeight(deck, 544) == 2 * kDeckGroupH + kDeckRowGap);
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
// framework. The filter's own numerical behaviour is filter_tests / filter_state_tests /
|
||||
// filter_morph_tests / filter_params_tests; this file asserts only the integration: that a
|
||||
// disengaged filter is bit-inert, that it sits between the pitch stage and the amp stage,
|
||||
// that each voice runs its own, and that the three cutoff-modulation sources reach it.
|
||||
// that each voice runs its own, that the three cutoff-modulation sources reach it, and that
|
||||
// the modulated corner moves continuously rather than in steps.
|
||||
|
||||
#include "../src/core/instrument/engine/voice.h"
|
||||
|
||||
@@ -247,6 +248,84 @@ static void testModAmountPolarityDrivesCutoffFromOppositeEnds() {
|
||||
CHECK(rms(rising, 10000, 12000) > rms(falling, 10000, 12000));
|
||||
}
|
||||
|
||||
// The corner must track the envelope CONTINUOUSLY. A retired revision gated the re-solve on the
|
||||
// modulated position crossing one step of a 2048-step quantization of the sweep, which
|
||||
// staircased the corner in ~5.8-cent jumps; these two assertions fail if any such quantizer
|
||||
// comes back, at either end of the path.
|
||||
static void testTheSolvedCornerIsContinuousUnderSubQuantumCutoffSteps() {
|
||||
// The SOLVE end: positions a tenth of the retired quantum apart must each land on their own
|
||||
// corner, strictly ordered. A quantizer anywhere in the solve collapses neighbours onto one g.
|
||||
flt::VoiceFilter f;
|
||||
flt::FilterSettings s;
|
||||
s.cutoffNorm = 0.5f;
|
||||
f.prepare(s, static_cast<double>(kRate));
|
||||
|
||||
const float step = 1.0f / 20480.0f; // a tenth of the retired 1/2048 quantum
|
||||
float prev = f.coeffs().g;
|
||||
for (int i = 1; i <= 200; ++i) {
|
||||
f.setCutoffNorm(0.5f + static_cast<float>(i) * step, static_cast<double>(kRate));
|
||||
const float g = f.coeffs().g;
|
||||
CHECK(g > prev); // strictly monotone: every sub-quantum step moves the corner
|
||||
prev = g;
|
||||
}
|
||||
// Q, morph and drive are untouched by a cutoff-only re-solve, so k and the folded mix must
|
||||
// read exactly what prepare() left — that equality is what makes the cheap path legitimate.
|
||||
flt::VoiceFilter full;
|
||||
flt::FilterSettings s2 = s;
|
||||
s2.cutoffNorm = 0.5f + 200.0f * step;
|
||||
full.prepare(s2, static_cast<double>(kRate));
|
||||
CHECK(f.coeffs().g == full.coeffs().g);
|
||||
CHECK(f.coeffs().k == full.coeffs().k);
|
||||
CHECK(f.mix().m0 == full.mix().m0);
|
||||
CHECK(f.mix().m1 == full.mix().m1);
|
||||
CHECK(f.mix().m2 == full.mix().m2);
|
||||
}
|
||||
|
||||
static void testAModulationTooSmallToCrossTheRetiredQuantumStillMovesTheVoice() {
|
||||
// The VOICE end: a depth of 1/8192 sweeps the cutoff by an eighth of the retired quantum
|
||||
// from a position that sits exactly on a quantum boundary — under the old gate `step` never
|
||||
// changed, so the whole sweep rendered bit-identically to a static filter. It must not now.
|
||||
const auto sweep = [](double modAmount) {
|
||||
SampleData s = periodicSine(8000, 64);
|
||||
s.play.adsr = flatAdsr();
|
||||
s.play.filter = engagedFilter(0.5f, 0.6f, 1.0f);
|
||||
s.play.filter.modAmount = modAmount;
|
||||
s.play.filter.env.attackFrames = 6000;
|
||||
s.play.filter.env.sustainLevel = 1.0;
|
||||
return render(s, 60, 100, 6000);
|
||||
};
|
||||
|
||||
const std::vector<double> stat = sweep(0.0);
|
||||
const std::vector<double> tiny = sweep(1.0 / 8192.0);
|
||||
std::size_t differing = 0;
|
||||
for (std::size_t i = 0; i < stat.size(); ++i) {
|
||||
if (stat[i] != tiny[i]) ++differing;
|
||||
}
|
||||
CHECK(differing > stat.size() / 2);
|
||||
}
|
||||
|
||||
// The steady-state guard for the unquantized re-solve: with no modulation the voice must be
|
||||
// EXACTLY one prepare() at the base cutoff over the source, bit for bit. A note at its root with
|
||||
// unity key-track, a flat amp envelope and a flat velocity curve reduces the whole voice path to
|
||||
// that, so any drift in what start() solves shows up as a bit difference here.
|
||||
static void testAnUnmodulatedVoiceIsBitIdenticalToASinglePreparedFilter() {
|
||||
SampleData s = periodicSine(4000, 64);
|
||||
s.play.adsr = flatAdsr();
|
||||
s.play.filter = engagedFilter(0.35f, 0.7f, 0.25f);
|
||||
s.play.filter.settings.driveNorm = 0.4f;
|
||||
const std::vector<double> got = render(s, 60, 100, 4000);
|
||||
|
||||
flt::VoiceFilter ref;
|
||||
ref.prepare(s.play.filter.settings, static_cast<double>(kRate));
|
||||
bool sawSignal = false;
|
||||
for (std::size_t i = 0; i < got.size(); ++i) {
|
||||
const double want = static_cast<double>(ref.process(0, s.frames[i]));
|
||||
CHECK(got[i] == want);
|
||||
if (std::fabs(want) > 1e-6) sawSignal = true;
|
||||
}
|
||||
CHECK(sawSignal); // bit-equality over silence would prove nothing
|
||||
}
|
||||
|
||||
static void testVelocityAndKeyTrackingReachCutoffAndAreNoOpsAtTheirDefaults() {
|
||||
// Playback key-tracking off, so both notes read the source at the SAME rate and the only
|
||||
// note-dependent difference left is the filter's own key-tracking.
|
||||
@@ -349,6 +428,9 @@ int main() {
|
||||
testFilterSeesThePreAmpSignalSoAmpGainScalesTheResultLinearly();
|
||||
testTwoVoicesAtDifferentEnvelopePhasesFilterIndependently();
|
||||
testModAmountPolarityDrivesCutoffFromOppositeEnds();
|
||||
testTheSolvedCornerIsContinuousUnderSubQuantumCutoffSteps();
|
||||
testAModulationTooSmallToCrossTheRetiredQuantumStillMovesTheVoice();
|
||||
testAnUnmodulatedVoiceIsBitIdenticalToASinglePreparedFilter();
|
||||
testVelocityAndKeyTrackingReachCutoffAndAreNoOpsAtTheirDefaults();
|
||||
testNoteOnResetsTheFilterSoAPreviousNoteCannotLeak();
|
||||
testStereoRenderOfAMonoSampleMirrorsTheMonoResultExactly();
|
||||
|
||||
Reference in New Issue
Block a user