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:
2026-07-30 18:12:10 -04:00
parent 39389c1183
commit 0cfd9b6236
14 changed files with 237 additions and 88 deletions
+1 -1
View File
@@ -217,7 +217,7 @@ slider couldn't. Two pure modules split the forward (draw) and inverse (edit) ma
### `ui/`
- `editor_geometry` (`core/instrument/ui`) — the shared geometry VOCABULARY every instrument UI module speaks: the `core::ui::Rect` alias, `contains()`, and `OverlayArea` (a one-field `Rect` wrapper, no implicit conversion from `Rect`). Header-only (an INTERFACE CMake target), so it carries no layout of its own.
- `sample_bands` — **THE band-stack allocator**, and the only module that owns the Sample face's vertical inventory: three bands top-to-bottom (CHROME toolbar+control row / WAVEFORM elastic, floored at two stacked lanes / DECKS bottom-anchored at the knob deck's own wrapped height), plus the waveform band's lane split (`waveformLanes` takes a resolved `LaneSplit`, not a raw bool — only `waveformSurface` folds the source-channel-count decision in). A shared READ-ONLY surface for every band owner — a band's interior module lays out inside the rect it is handed and never re-allocates the stack.
- `sample_bands` — **THE band-stack allocator**, and the only module that owns the Sample face's vertical inventory — including `kEditorMinWidth`/`kEditorMinHeight`, the editor's client-area floor, which IS its default size (the shell's `checkSizeConstraint` and opening `ViewRect` both read it; the face grows, never shrinks below what the stack is laid out for). Three bands top-to-bottom (CHROME toolbar+control row / WAVEFORM elastic, floored at two stacked lanes / DECKS bottom-anchored at the knob deck's own wrapped height), plus the waveform band's lane split (`waveformLanes` takes a resolved `LaneSplit`, not a raw bool — only `waveformSurface` folds the source-channel-count decision in). A shared READ-ONLY surface for every band owner — a band's interior module lays out inside the rect it is handed and never re-allocates the stack.
- `sample_chrome` — the CHROME band's interior: the toolbar row (title + the whole right-anchored control run — preview, velocity knob cell, curve button, channel toggle, Browse) over the strip row, which the piano strip owns outright. The title takes what the run leaves; the strip takes its whole row, inset only by the shared band pad so it lines up with the waveform band beneath.
- `keyboard_strip` — piano-keyboard strip: true white/black key geometry (whites tiled at one width, blacks overlaid at one width and height, straddling their boundary), hit-test resolving black-over-white by zone, root-marker rect, the absolute-position drag resolver, and MIDI note naming under the C4 convention. **Same-class keys are one integer width by construction; the residue of an indivisible band width (`w % 75`, up to 74 px) lands in symmetric end margins, never in a key** — uniform widths and gap-free edge-to-edge tiling cannot both hold, and uniformity wins.
- `waveform_view` — the WAVEFORM band's interior: `waveformSurface` resolves the drawn lane(s) (two stacked lanes, L over R, only when the mode is stereo AND the source has a second channel — a mono source under stereo mode is dual-mono and draws one lane) plus **the** overlay area, and `laneEnvelope` splits one multi-channel envelope pass per lane. Also maps frame span linearly across a rect; generic named draggable markers with drag-delta resolver, clamp, and zero-crossing snap.
+26 -3
View File
@@ -23,6 +23,30 @@ merely tidy. Five files, one responsibility each:
- `voice_filter``FilterSettings` and `VoiceFilter`, the concrete per-voice type.
`process()` is defined in the header.
### The cutoff is the only control that re-solves per frame, and it re-solves alone
`prepare()` is the full solve; `setCutoffNorm()` is the per-frame one. The split exists because
**a modulated corner must move continuously** — Daniel's ruling, replacing a retired 2048-step
quantizer that staircased the sweep in ~5.8-cent jumps — and a full `prepare()` per frame is the
wasteful way to buy that. Only `g = tan(pi*fc/sr)` depends on cutoff: Q's parabola, the morph's
`cos`/`sin`, and the folded mix (a function of the weights and `k` alone) do not, so
`setCutoffNorm` re-derives none of them and reuses the `q_` cached at `prepare()`.
Measured at 48 kHz, MSVC `/O2`, net of the sweep generator and the kernel:
| per frame | ns |
|---|---|
| kernel alone, no re-solve | 2.8 |
| full `prepare()` | 56.9 |
| `setCutoffNorm`, constant logs recomputed | 22.8 |
| `setCutoffNorm`, constant logs hoisted (shipped) | 15.5 |
The last row is 16 voices of continuously-swept filter at ~1.2% of one core — affordable, which
is why nothing approximates `tan` here. The hoist is in `filter_params.cpp`: the sweep endpoints
and the Q parabola are functions of compile-time constants, and recomputing those five
logarithms per frame cost more than the solve they fed. **Do not put a quantizer back on the
control value to save the solve** — make the solve cheaper instead.
## Invariants
### No vtable on the per-sample path
@@ -225,9 +249,8 @@ topology.
- **Measuring a null needs a ring-time-adequate settle window.** At `Q = 10` the leftover
transient alone reads as 52 dB after 0.15 s and would be mistaken for the noise floor.
- **The call site is `Voice::advanceFrame`**, between the pitch stage and the amp multiply.
It re-`prepare()`s only when the modulated cutoff crosses one step of a 2048-step
quantization of the sweep, because a solve costs a `tan` plus the morph's `cos`/`sin` — an
unmodulated voice must not pay for them per frame (see `voice.h`'s `kFilterModSteps`).
It re-solves the corner on **every frame the modulated cutoff actually moves — unquantized**,
so the corner glides rather than staircasing.
- **Decay to the denormal floor is a fixed wall-clock time, not a sample count.** A test
budget expressed in samples is therefore itself a rate assumption — a fixed 20000 samples
is ample at 48k and expires mid-decay at 96k and above.
@@ -15,32 +15,36 @@ struct QCurve {
double a, b, c;
};
QCurve qCurve() {
QCurve solveQCurve() {
const double lo = std::log(static_cast<double>(kFilterQMin));
const double mid = std::log(static_cast<double>(kFilterQCenter));
const double hi = std::log(static_cast<double>(kFilterQMax));
return {lo, 4.0 * mid - 3.0 * lo - hi, 2.0 * lo + 2.0 * hi - 4.0 * mid};
}
// Functions of compile-time constants alone, so they resolve once at static init rather than
// per call. Load-bearing rather than tidy: a modulated cutoff re-solves EVERY FRAME, and
// recomputing these logarithms of literals cost more than the solve they feed.
const double kLogCutoffMin = std::log(static_cast<double>(kFilterCutoffMinHz));
const double kLogCutoffSpan =
std::log(static_cast<double>(kFilterCutoffMaxHz)) - kLogCutoffMin;
const QCurve kQCurve = solveQCurve();
} // namespace
float filterCutoffHzFromNorm(float norm) {
const double lo = std::log(static_cast<double>(kFilterCutoffMinHz));
const double hi = std::log(static_cast<double>(kFilterCutoffMaxHz));
return static_cast<float>(std::exp(lo + clamp01(norm) * (hi - lo)));
return static_cast<float>(std::exp(kLogCutoffMin + clamp01(norm) * kLogCutoffSpan));
}
float filterNormFromCutoffHz(float hz) {
if (!(hz > 0.0f)) return 0.0f;
const double lo = std::log(static_cast<double>(kFilterCutoffMinHz));
const double hi = std::log(static_cast<double>(kFilterCutoffMaxHz));
return static_cast<float>(clamp01((std::log(static_cast<double>(hz)) - lo) / (hi - lo)));
return static_cast<float>(
clamp01((std::log(static_cast<double>(hz)) - kLogCutoffMin) / kLogCutoffSpan));
}
float filterQFromNorm(float norm) {
const QCurve k = qCurve();
const double n = clamp01(norm);
return static_cast<float>(std::exp(k.a + n * (k.b + k.c * n)));
return static_cast<float>(std::exp(kQCurve.a + n * (kQCurve.b + kQCurve.c * n)));
}
float filterDriveDepthFromNorm(float norm) {
@@ -53,7 +57,7 @@ float filterNormFromQ(float q) {
if (q >= kFilterQMax) return 1.0f;
// Clamping first is load-bearing, not just tidy: the parabola peaks at log Q well below
// an arbitrarily large q, so an unclamped out-of-range value has no real root at all.
const QCurve k = qCurve();
const QCurve& k = kQCurve;
const double d = k.b * k.b - 4.0 * k.c * (k.a - std::log(static_cast<double>(q)));
if (!(d >= 0.0)) return 0.0f;
// Of the two roots only this one lies on the rising branch inside [0,1]; the parabola's
@@ -3,8 +3,8 @@
namespace reasampler::instrument::engine::filter {
void VoiceFilter::prepare(const FilterSettings& settings, double sampleRate) {
coeffs_ = svfCoeffs(filterCutoffHzFromNorm(settings.cutoffNorm),
filterQFromNorm(settings.resonanceNorm), sampleRate);
q_ = filterQFromNorm(settings.resonanceNorm);
coeffs_ = svfCoeffs(filterCutoffHzFromNorm(settings.cutoffNorm), q_, sampleRate);
if (sampleRate > 0.0) {
mix_ = morphMix(morphWeights(settings.morphNorm, settings.morphLaw), coeffs_.k);
} else {
@@ -19,6 +19,10 @@ void VoiceFilter::prepare(const FilterSettings& settings, double sampleRate) {
driven_ = driveDepth_ != 0.0f;
}
void VoiceFilter::setCutoffNorm(float cutoffNorm, double sampleRate) {
coeffs_ = svfCoeffs(filterCutoffHzFromNorm(cutoffNorm), q_, sampleRate);
}
void VoiceFilter::reset() {
for (State& s : state_) s = State{};
}
@@ -44,6 +44,15 @@ public:
// live parameter move glides instead of clicking; call reset() at note-on.
void prepare(const FilterSettings& settings, double sampleRate);
// Re-solves ONLY the cutoff-dependent coefficients, for a cutoff that moves per frame under
// envelope modulation — cheap enough that the corner never needs quantizing (see
// filter/CLAUDE.md for the numbers). Neither Q's parabola nor the morph's cos/sin enters
// `g`, and the folded mix is a function of the weights and k alone, so a cutoff move
// re-derives none of them. State preserved, exactly as prepare(). `sampleRate` must be the
// one the last prepare() ran at: prepare() owns the non-positive-rate bypass mix, and this
// deliberately leaves that mix alone.
void setCutoffNorm(float cutoffNorm, double sampleRate);
void reset();
// Hot path. `channel` must be in [0, kMaxChannels).
@@ -111,6 +120,7 @@ public:
private:
SvfCoeffs coeffs_{};
MorphMix mix_{};
float q_ = 1.0f; // cached at prepare() so setCutoffNorm need not re-solve Q's parabola
float driveDepth_ = 0.0f;
bool driven_ = false; // driveDepth_ != 0, cached so process() branches on a bool, not a float compare
State state_[kMaxChannels]{};
+8 -1
View File
@@ -97,7 +97,6 @@ void Voice::start(int note, int velocity, const SampleData& sample, bool declick
// per-frame path, exactly as the amp's velocityGain_ does.
filterOn_ = p.filter.enabled;
if (filterOn_) {
filterSettings_ = p.filter.settings;
filterCutoffNorm_ = static_cast<double>(p.filter.settings.cutoffNorm);
filterModAmount_ = p.filter.modAmount;
filterKeyTrack_ = p.filter.keyTrack;
@@ -108,6 +107,14 @@ void Voice::start(int note, int velocity, const SampleData& sample, bool declick
filterEnv_.noteOn();
filter_.reset();
updateFilterCutoffBase(note);
// The note's ONE full solve — Q, morph and drive are constants for its lifetime, so
// every later re-solve is the cheap cutoff-only path. A modulated voice supersedes this
// cutoff in tickFilterCutoff on its first frame, before any sample reaches the kernel.
instrument::engine::filter::FilterSettings s = p.filter.settings;
s.cutoffNorm = filterBaseCutoff_;
filter_.prepare(s, filterRate_);
filterSolvedCutoff_ = filterBaseCutoff_;
filterSolved_ = true;
}
// Prime the already-sized per-channel shifters with the first window of the actual
+25 -30
View File
@@ -51,14 +51,6 @@ inline double filterNormPerOctave() {
flt::filterNormFromCutoffHz(flt::kFilterCutoffMinHz));
}
// A modulated cutoff re-solves the SVF coefficients, which costs a tan() plus the morph's
// cos/sin — so the solve is gated on the modulated position crossing one step of this
// quantization of the sweep. 2048 steps over three decades is ~0.06 semitone, far under the
// ear's resolution for a filter corner. tickFilterCutoff's own modAmount==0 early-out (see
// there) skips the envelope/clamp/quantize math too, so a static, already-solved voice pays
// two cheap compares per frame there, never the tan/cos/sin.
inline constexpr int kFilterModSteps = 2048;
// Takeover declick: a restart of a sounding voice (mono retrigger takeover/fallback or a
// poly at-cap steal) hard-cuts the old tone in one frame — a step discontinuity that clicks.
// When the caller opts in (start()'s declickTakeover), start() records the last rendered
@@ -178,26 +170,29 @@ private:
return amp;
}
// Advances the filter envelope and re-solves the filter's coefficients when the modulated
// cutoff has moved a whole quantization step (see kFilterModSteps); state preservation
// across that solve is voice_filter's own contract (voice_filter.h / filter/CLAUDE.md).
// Advances the filter envelope and re-solves the corner from the modulated cutoff. The
// solve is UNQUANTIZED: the corner tracks the envelope continuously, so a sweep glides
// rather than staircasing. State preservation across the solve is voice_filter's own
// contract (voice_filter.h / filter/CLAUDE.md). Do not reintroduce a step quantizer on the
// control value to save the solve — setCutoffNorm exists to make the solve cheap instead.
//
// filterModAmount_ is fixed for the note's lifetime (set once in start()), so once the
// first solve has run (filterModStep_ != -1) a zero depth can only ever re-derive the same
// cutoff — skip the envelope tick, clamp, and quantization entirely rather than pay them
// to land on the answer already solved. filterModStep_ == -1 (forced by start()/retune()
// via updateFilterCutoffBase) still falls through here so the base cutoff's own solve runs.
// Two exact skips, neither of which rounds the control: filterModAmount_ is fixed for the
// note's lifetime, so a zero depth can only ever re-derive the cutoff already solved; and a
// held envelope (sustain, or finished) reproduces the previous position bit-for-bit. Both
// compare the value itself, so they can never suppress a move the ear would hear.
// filterSolved_ == false (forced by start()/retune() via updateFilterCutoffBase) falls
// through both so a moved base always re-solves.
void tickFilterCutoff() {
if (filterModAmount_ == 0.0 && filterModStep_ != -1) return;
if (filterModAmount_ == 0.0 && filterSolved_) return;
double cut = static_cast<double>(filterBaseCutoff_) +
filterModAmount_ * filterEnv_.tick();
if (cut < 0.0) cut = 0.0;
if (cut > 1.0) cut = 1.0;
const int step = static_cast<int>(cut * kFilterModSteps + 0.5);
if (step == filterModStep_) return;
filterModStep_ = step;
filterSettings_.cutoffNorm = static_cast<float>(cut);
filter_.prepare(filterSettings_, filterRate_);
const float cutNorm = static_cast<float>(cut);
if (filterSolved_ && cutNorm == filterSolvedCutoff_) return;
filterSolvedCutoff_ = cutNorm;
filterSolved_ = true;
filter_.setCutoffNorm(cutNorm, filterRate_);
}
// The cutoff position before the envelope: the stored knob position plus this note's
@@ -213,7 +208,7 @@ private:
if (base < 0.0) base = 0.0;
if (base > 1.0) base = 1.0;
filterBaseCutoff_ = static_cast<float>(base);
filterModStep_ = -1; // forces the next frame to solve
filterSolved_ = false; // forces the next frame to solve
}
// Seeds the takeover compensation on the first frame after a restart: the ramp is the
@@ -479,21 +474,21 @@ private:
bool amplitudeDone_ = false; // set when the active amplitude envelope finished
// The voice's OWN filter and filter envelope — per-voice, never shared, so two notes at
// different envelope phases are filtered independently. filterSettings_ is this note's
// copy of the control positions with cutoffNorm overwritten per solve; filterCutoffNorm_
// keeps the unmodulated knob position the base is rebuilt from. filterRate_ <= 0 makes
// prepare() bypass rather than invent a rate.
// different envelope phases are filtered independently. filterCutoffNorm_ keeps the
// unmodulated knob position the base is rebuilt from. Q, morph and drive are note-constants
// solved once by start()'s prepare(), which is why every later re-solve is cutoff-only.
// filterRate_ <= 0 makes prepare() bypass rather than invent a rate.
instrument::engine::filter::VoiceFilter filter_;
AdsrEnvelope filterEnv_;
instrument::engine::filter::FilterSettings filterSettings_;
bool filterOn_ = false;
double filterRate_ = 0.0;
double filterCutoffNorm_ = 1.0;
double filterModAmount_ = 0.0;
double filterVelOffset_ = 0.0; // velAmount * velocityCurve.eval(velocity), fixed per note
double filterKeyTrack_ = 0.0;
float filterBaseCutoff_ = 1.0f; // cutoff before the envelope, clamped
int filterModStep_ = -1; // last solved cutoff step; -1 forces a solve
float filterBaseCutoff_ = 1.0f; // cutoff before the envelope, clamped
float filterSolvedCutoff_ = 1.0f; // the position the live coefficients were solved from
bool filterSolved_ = false; // false forces the next frame to solve
// pitchEngine_ selects Varispeed (ratio bias) vs Preserve (source-rate read + shifter).
// shiftL_/shiftR_ transpose the Preserve output per channel. pitchEnv_ rides either engine.
+3 -1
View File
@@ -52,7 +52,9 @@ reasampler_test(knob_deck LINK knob_deck)
reasampler_pure_library(deck_groups
SOURCES deck_groups.cpp
LINK PUBLIC knob_deck velocity_curve peaks)
reasampler_test(deck_groups LINK deck_groups)
# sample_bands is linked directly for the test only: the deck-fits-the-floor-window assertion
# needs the band allocator deck_groups itself has no reason to depend on.
reasampler_test(deck_groups LINK deck_groups sample_bands)
reasampler_pure_library(curve_popup SOURCES curve_popup.cpp LINK PUBLIC editor_geometry)
reasampler_test(curve_popup LINK curve_popup)
+7
View File
@@ -12,6 +12,13 @@ namespace reasampler::instrument::ui {
// Shared outer inset every band honours horizontally.
inline constexpr int kPad = 8;
// The editor's client-area floor, which IS its default size: the band stack is laid out for
// exactly this, and there is no scroll, so anything smaller pushes the deck band off the
// window bottom (computeSampleBands' waveform-floor-wins degrade). Growing is fine — the
// waveform band is the elastic one. Both the enforced minimum and the opening rect read this.
inline constexpr int kEditorMinWidth = 840;
inline constexpr int kEditorMinHeight = 620;
// Chrome band: the toolbar row (title + nav) stacked over the control row (piano strip,
// preview, velocity knob, curve button, channel toggle). sample_chrome partitions it.
inline constexpr int kTitleHeight = 26;
+10 -10
View File
@@ -44,17 +44,17 @@ tresult PLUGIN_API ReaSamplerEditor::canResize() {
}
tresult PLUGIN_API ReaSamplerEditor::checkSizeConstraint(ViewRect* rect) {
// Enforce a minimum usable floor: at least 560 wide and 460 tall. The host calls this
// before every resize; clamp the proposed rect in place and return kResultTrue so the host
// applies the (possibly adjusted) rect rather than the raw user drag. 560x460 keeps the
// Sample face's title + hero waveform + cluster + a few control rows visible (the control
// strip clips gracefully below the panel bottom); anything smaller would clip essential UI.
// The default 840x620 is above this floor.
constexpr int kMinW = 560;
constexpr int kMinH = 460;
// The floor is the default size (sample_bands): the face can be grown, never shrunk below
// what its band stack is laid out for. The host calls this before every resize; clamp the
// proposed rect in place and return kResultTrue so the host applies the adjusted rect
// rather than the raw user drag.
if (!rect) return kResultFalse;
if (rect->getWidth() < kMinW) rect->right = rect->left + kMinW;
if (rect->getHeight() < kMinH) rect->bottom = rect->top + kMinH;
if (rect->getWidth() < instrument::ui::kEditorMinWidth) {
rect->right = rect->left + instrument::ui::kEditorMinWidth;
}
if (rect->getHeight() < instrument::ui::kEditorMinHeight) {
rect->bottom = rect->top + instrument::ui::kEditorMinHeight;
}
return kResultTrue;
}
+3 -4
View File
@@ -38,10 +38,9 @@ using util::readFileBytes;
ReaSamplerEditor::ReaSamplerEditor(ReaSamplerProcessor* processor)
: CPluginView(nullptr), processor_(processor) {
// Default view size, tuned to the band heights: chrome + two-lane waveform + the deck
// (now two rows since the filter group). 840x620 clears the full face without scroll on
// 1080p.
ViewRect r(0, 0, 840, 620);
// The default IS the enforced floor (checkSizeConstraint) — the face opens at the size its
// band stack is laid out for and can only be grown from there.
ViewRect r(0, 0, instrument::ui::kEditorMinWidth, instrument::ui::kEditorMinHeight);
setRect(r);
}
+36 -20
View File
@@ -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");
+5 -5
View File
@@ -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);
+83 -1
View File
@@ -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();