fix: guard filter tail NaNs, skip static-filter envelope work, pin stereo filter path, correct stale comments

Codec fallback for non-finite filter fields, a modAmount==0 early-out in tickFilterCutoff,
new stereo render tests for the dual-mono mirror, and comment/test accuracy fixes flagged
in review (stale deck-width claims, restated invariants, drifted CMake link comments).
This commit is contained in:
2026-07-30 17:35:45 -04:00
parent 67215509cb
commit 39389c1183
14 changed files with 137 additions and 41 deletions
+6 -5
View File
@@ -96,10 +96,8 @@ struct PitchEnvParams {
// normalized control positions verbatim rather than a parallel set, so no control range is
// re-derived here; `filter_params.h` owns every law that maps them to Hz/Q/depth.
//
// The three modulation depths all land in that same normalized cutoff domain and sum before a
// single clamp: `modAmount` scales the per-frame filter envelope, `velAmount` scales the
// note-on velocity through `velocityCurve`, and `keyTrack` moves cutoff by octaves per octave
// above the root. All three are zero/neutral by default.
// The three modulation depths below land in that same normalized cutoff domain and sum
// before a single clamp; all three are zero/neutral by default.
struct FilterParams {
bool enabled = false;
instrument::engine::filter::FilterSettings settings;
@@ -109,7 +107,10 @@ struct FilterParams {
AdsrParams env; // the same staged AHDSR the amp runs; frames
// Shapes velocity before velAmount scales it. Linear rather than the amp's flat() default
// because a flat curve under a depth control would make every velocity the same offset;
// the no-op at rest is velAmount == 0, not the curve.
// the no-op at rest is velAmount == 0, not the curve. NOTE: this default only governs a
// FRESH FilterParams — the shared codec's corrupt/truncated-point-list repair
// (VelocityCurve::fromPoints, used for both this curve and the amp's) still degrades to
// flat() regardless, since that repair has no curve-specific fallback.
VelocityCurve velocityCurve = VelocityCurve::linear();
};
+2 -2
View File
@@ -92,8 +92,8 @@ void Voice::start(int note, int velocity, const SampleData& sample, bool declick
pitchEnv_.configure(p.pitchEnv);
pitchEnv_.noteOn();
// Filter: fresh integrators per note (prepare() preserves state on purpose, so a note-on
// is the one place that must clear it). Velocity maps through the curve once here, off the
// Filter: reset() clears integrator state for the new note (prepare() preserves it —
// voice_filter.h / filter/CLAUDE.md). Velocity maps through the curve once here, off the
// per-frame path, exactly as the amp's velocityGain_ does.
filterOn_ = p.filter.enabled;
if (filterOn_) {
+12 -4
View File
@@ -54,8 +54,9 @@ inline double filterNormPerOctave() {
// 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, and it collapses the solve to nothing across a static
// envelope stage: an unmodulated voice pays one integer compare per frame.
// 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
@@ -178,9 +179,16 @@ private:
}
// Advances the filter envelope and re-solves the filter's coefficients when the modulated
// cutoff has moved a whole quantization step (see kFilterModSteps). prepare() deliberately
// preserves integrator state, so a moving cutoff glides rather than clicking.
// 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).
//
// 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.
void tickFilterCutoff() {
if (filterModAmount_ == 0.0 && filterModStep_ != -1) return;
double cut = static_cast<double>(filterBaseCutoff_) +
filterModAmount_ * filterEnv_.tick();
if (cut < 0.0) cut = 0.0;
+2
View File
@@ -15,6 +15,8 @@ reasampler_test(bank_sync LINK bank_sync)
# The state codec is shared with the extension's preset-blob path, so it must link WITHOUT
# the voice engine: velocity_curve (the curve field) and master_gain (the wire gain cap) only.
# play_params.h also pulls in filter/'s headers (FilterSettings, MorphLaw) for the v9 filter
# tail -- plain value types, so no filter symbol is linked and this stays true.
reasampler_pure_library(component_state_io
SOURCES component_state_io.cpp
LINK PUBLIC velocity_curve master_gain)
@@ -162,9 +162,14 @@ void readFilterTail(ByteReader& r, InstrumentParams& p) {
f.settings.driveNorm = static_cast<float>(bitsToDouble(r.u64()));
f.settings.morphLaw = (r.u8() != 0) ? engine::filter::MorphLaw::HighNotchLow
: engine::filter::MorphLaw::HighBandLow;
f.modAmount = bitsToDouble(r.u64());
f.velAmount = bitsToDouble(r.u64());
f.keyTrack = bitsToDouble(r.u64());
// Same non-finite-falls-back-to-neutral guard as the v8 master gain above: these three
// reach Voice::tickFilterCutoff's clamp compares and a static_cast<int>, both UB on NaN.
double modAmount = bitsToDouble(r.u64());
double velAmount = bitsToDouble(r.u64());
double keyTrack = bitsToDouble(r.u64());
f.modAmount = std::isfinite(modAmount) ? modAmount : 0.0;
f.velAmount = std::isfinite(velAmount) ? velAmount : 0.0;
f.keyTrack = std::isfinite(keyTrack) ? keyTrack : 0.0;
f.env.attackSeconds = bitsToDouble(r.u64());
f.env.holdSeconds = bitsToDouble(r.u64());
f.env.decaySeconds = bitsToDouble(r.u64());
+5 -2
View File
@@ -44,8 +44,11 @@ reasampler_test(envelope_edit LINK envelope_edit)
reasampler_pure_library(knob_deck SOURCES knob_deck.cpp LINK PUBLIC editor_geometry)
reasampler_test(knob_deck LINK knob_deck)
# The deck's group COMPOSITION, split from its layout: knob_deck stays engine-free, while this
# names the controls and so reads PlayMode (velocity_curve comes along with play_params.h).
# The deck's group COMPOSITION, split from its layout: knob_deck stays engine-free (see
# core/instrument/CLAUDE.md's deck_groups entry for why this module, not knob_deck, reads
# PlayMode). velocity_curve is the filter's own curve field; peaks is play_params.h's
# AudioSample dependency. play_params.h also drags in filter/'s headers (FilterSettings,
# MorphLaw) for the v9 filter tail -- plain value types, no filter symbol linked.
reasampler_pure_library(deck_groups
SOURCES deck_groups.cpp
LINK PUBLIC knob_deck velocity_curve peaks)
+1 -1
View File
@@ -72,7 +72,7 @@ std::vector<DeckGroupDesc> sampleDeckGroups(PlayMode playMode) {
id(DeckParam::kRelease)};
} else {
// Trigger, time-ordered left-to-right (Fade In / Length % / Fade Out — matches
// the drawn envelope), plus the two reserved blanks that hold the Gate width.
// the drawn envelope), plus two blanks (see knob_deck.h's blank-cell contract).
amp.cellIds = {id(DeckParam::kTrigFadeIn), id(DeckParam::kTrigLength),
id(DeckParam::kTrigFadeOut), -1, -1};
}
+2 -3
View File
@@ -68,9 +68,8 @@ enum DeckGroupId {
};
// The deck's groups, left to right, in SIGNAL-FLOW order: pitch -> filter -> amp, then the
// two instance-wide groups. `playMode` picks the AMP group's face; its width is
// mode-independent (Trigger leaves two blank cells) so a mode flip never reflows the
// neighbouring groups.
// two instance-wide groups. `playMode` picks the AMP group's face, via knob_deck's blank-cell
// reservation (knob_deck.h) so a mode flip never reflows the neighbouring groups.
std::vector<DeckGroupDesc> sampleDeckGroups(PlayMode playMode);
// The deck's BIPOLAR knob law: 0.5 of the knob's travel is zero depth, the ends are -1 and
+3 -3
View File
@@ -1,8 +1,8 @@
// editor_controls.cpp — the ReaSamplerEditor's parameter plumbing: the band-stack layout
// resolve every paint/hit-test path shares, the control-value domain maps (controlValue /
// applyControl — seconds/fraction/frames <-> normalized 0..1), the knob-deck group
// descriptors + control-id<->value binding, and the envelope pack/unpack (the trigger-seam
// converter). Value logic only — no painting, no window plumbing.
// applyControl — seconds/fraction/frames <-> normalized 0..1), the control-id<->value binding
// against the pure `deck_groups` module's descriptors, and the envelope pack/unpack (the
// trigger-seam converter). Value logic only — no painting, no window plumbing.
#include "shell/instrument/reasampler_editor.h"
+3 -2
View File
@@ -38,8 +38,9 @@ using util::readFileBytes;
ReaSamplerEditor::ReaSamplerEditor(ReaSamplerProcessor* processor)
: CPluginView(nullptr), processor_(processor) {
// Default view size, tuned to the three band heights: chrome + two-lane waveform +
// deck row. 840x620 clears the full face without scroll on 1080p.
// 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);
setRect(r);
}