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;