diff --git a/src/core/instrument/CLAUDE.md b/src/core/instrument/CLAUDE.md index 83ec8fb..ee4cea9 100644 --- a/src/core/instrument/CLAUDE.md +++ b/src/core/instrument/CLAUDE.md @@ -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. diff --git a/src/core/instrument/engine/filter/CLAUDE.md b/src/core/instrument/engine/filter/CLAUDE.md index 607e13f..4d22dd4 100644 --- a/src/core/instrument/engine/filter/CLAUDE.md +++ b/src/core/instrument/engine/filter/CLAUDE.md @@ -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. diff --git a/src/core/instrument/engine/filter/filter_params.cpp b/src/core/instrument/engine/filter/filter_params.cpp index 927d194..d22e240 100644 --- a/src/core/instrument/engine/filter/filter_params.cpp +++ b/src/core/instrument/engine/filter/filter_params.cpp @@ -15,32 +15,36 @@ struct QCurve { double a, b, c; }; -QCurve qCurve() { +QCurve solveQCurve() { const double lo = std::log(static_cast(kFilterQMin)); const double mid = std::log(static_cast(kFilterQCenter)); const double hi = std::log(static_cast(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(kFilterCutoffMinHz)); +const double kLogCutoffSpan = + std::log(static_cast(kFilterCutoffMaxHz)) - kLogCutoffMin; +const QCurve kQCurve = solveQCurve(); + } // namespace float filterCutoffHzFromNorm(float norm) { - const double lo = std::log(static_cast(kFilterCutoffMinHz)); - const double hi = std::log(static_cast(kFilterCutoffMaxHz)); - return static_cast(std::exp(lo + clamp01(norm) * (hi - lo))); + return static_cast(std::exp(kLogCutoffMin + clamp01(norm) * kLogCutoffSpan)); } float filterNormFromCutoffHz(float hz) { if (!(hz > 0.0f)) return 0.0f; - const double lo = std::log(static_cast(kFilterCutoffMinHz)); - const double hi = std::log(static_cast(kFilterCutoffMaxHz)); - return static_cast(clamp01((std::log(static_cast(hz)) - lo) / (hi - lo))); + return static_cast( + clamp01((std::log(static_cast(hz)) - kLogCutoffMin) / kLogCutoffSpan)); } float filterQFromNorm(float norm) { - const QCurve k = qCurve(); const double n = clamp01(norm); - return static_cast(std::exp(k.a + n * (k.b + k.c * n))); + return static_cast(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(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 diff --git a/src/core/instrument/engine/filter/voice_filter.cpp b/src/core/instrument/engine/filter/voice_filter.cpp index b22278e..f06fae3 100644 --- a/src/core/instrument/engine/filter/voice_filter.cpp +++ b/src/core/instrument/engine/filter/voice_filter.cpp @@ -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{}; } diff --git a/src/core/instrument/engine/filter/voice_filter.h b/src/core/instrument/engine/filter/voice_filter.h index f9b83cb..ef9c466 100644 --- a/src/core/instrument/engine/filter/voice_filter.h +++ b/src/core/instrument/engine/filter/voice_filter.h @@ -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]{}; diff --git a/src/core/instrument/engine/voice.cpp b/src/core/instrument/engine/voice.cpp index 66e6b99..5b9f834 100644 --- a/src/core/instrument/engine/voice.cpp +++ b/src/core/instrument/engine/voice.cpp @@ -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(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 diff --git a/src/core/instrument/engine/voice.h b/src/core/instrument/engine/voice.h index 071062e..435573d 100644 --- a/src/core/instrument/engine/voice.h +++ b/src/core/instrument/engine/voice.h @@ -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(filterBaseCutoff_) + filterModAmount_ * filterEnv_.tick(); if (cut < 0.0) cut = 0.0; if (cut > 1.0) cut = 1.0; - const int step = static_cast(cut * kFilterModSteps + 0.5); - if (step == filterModStep_) return; - filterModStep_ = step; - filterSettings_.cutoffNorm = static_cast(cut); - filter_.prepare(filterSettings_, filterRate_); + const float cutNorm = static_cast(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(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. diff --git a/src/core/instrument/ui/CMakeLists.txt b/src/core/instrument/ui/CMakeLists.txt index 4919aa2..36749d3 100644 --- a/src/core/instrument/ui/CMakeLists.txt +++ b/src/core/instrument/ui/CMakeLists.txt @@ -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) diff --git a/src/core/instrument/ui/sample_bands.h b/src/core/instrument/ui/sample_bands.h index fe6c80e..69de15a 100644 --- a/src/core/instrument/ui/sample_bands.h +++ b/src/core/instrument/ui/sample_bands.h @@ -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; diff --git a/src/shell/instrument/editor_platform.cpp b/src/shell/instrument/editor_platform.cpp index 00df4af..0d6d2c5 100644 --- a/src/shell/instrument/editor_platform.cpp +++ b/src/shell/instrument/editor_platform.cpp @@ -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; } diff --git a/src/shell/instrument/editor_session.cpp b/src/shell/instrument/editor_session.cpp index 795deb4..0fbabbd 100644 --- a/src/shell/instrument/editor_session.cpp +++ b/src/shell/instrument/editor_session.cpp @@ -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); } diff --git a/tests/test_deck_groups.cpp b/tests/test_deck_groups.cpp index e3d40c3..c4fc5f9 100644 --- a/tests/test_deck_groups.cpp +++ b/tests/test_deck_groups.cpp @@ -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 #include @@ -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& 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 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 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 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(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"); diff --git a/tests/test_knob_deck.cpp b/tests/test_knob_deck.cpp index 88ba3a9..d48f316 100644 --- a/tests/test_knob_deck.cpp +++ b/tests/test_knob_deck.cpp @@ -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); diff --git a/tests/test_sampler_filter.cpp b/tests/test_sampler_filter.cpp index b24e932..ebd2a3c 100644 --- a/tests/test_sampler_filter.cpp +++ b/tests/test_sampler_filter.cpp @@ -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(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(i) * step, static_cast(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(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 stat = sweep(0.0); + const std::vector 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 got = render(s, 60, 100, 4000); + + flt::VoiceFilter ref; + ref.prepare(s.play.filter.settings, static_cast(kRate)); + bool sawSignal = false; + for (std::size_t i = 0; i < got.size(); ++i) { + const double want = static_cast(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();