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:
@@ -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.
|
||||
|
||||
@@ -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]{};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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