From 67215509cb0381eb4406ee481ab7dc044a1dceb8 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Thu, 30 Jul 2026 15:26:14 -0400 Subject: [PATCH] feat: run the per-voice filter between the pitch and amp stages, with its own deck Params ride the one parameter set; payload v8 -> v9, off by default. Deck composition moves to a pure deck_groups module in pitch -> filter -> amp order. --- CLAUDE.md | 2 +- src/core/instrument/CLAUDE.md | 9 +- src/core/instrument/engine/CMakeLists.txt | 9 +- src/core/instrument/engine/filter/CLAUDE.md | 14 +- src/core/instrument/engine/play_params.h | 28 +- src/core/instrument/engine/voice.cpp | 22 ++ src/core/instrument/engine/voice.h | 98 +++++- .../instrument/map/component_state_io.cpp | 84 +++-- src/core/instrument/map/component_state_io.h | 32 +- src/core/instrument/map/sample_map.cpp | 13 + src/core/instrument/map/sample_map.h | 16 + src/core/instrument/ui/CMakeLists.txt | 7 + src/core/instrument/ui/deck_groups.cpp | 100 ++++++ src/core/instrument/ui/deck_groups.h | 82 +++++ src/shell/instrument/CLAUDE.md | 4 +- src/shell/instrument/CMakeLists.txt | 2 +- src/shell/instrument/editor_controls.cpp | 156 ++++++---- src/shell/instrument/editor_input_deck.cpp | 40 ++- src/shell/instrument/editor_internal.h | 13 +- src/shell/instrument/editor_paint_deck.cpp | 57 +++- src/shell/instrument/reasampler_editor.h | 39 +-- tests/test_component_state_io.cpp | 190 ++++++++++- tests/test_deck_groups.cpp | 182 +++++++++++ tests/test_sample_map.cpp | 52 ++++ tests/test_sampler_filter.cpp | 294 ++++++++++++++++++ 25 files changed, 1354 insertions(+), 191 deletions(-) create mode 100644 src/core/instrument/ui/deck_groups.cpp create mode 100644 src/core/instrument/ui/deck_groups.h create mode 100644 tests/test_deck_groups.cpp create mode 100644 tests/test_sampler_filter.cpp diff --git a/CLAUDE.md b/CLAUDE.md index ecbaf8c..e18497f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -81,7 +81,7 @@ There is no hot-reload. Copy the built binary into REAPER's `UserPlugins/` folde | `src/core/audio/` | pure audio-data math | | `src/core/capture/` | pure logic behind the capture pillar | | `src/core/instrument/` | pure VST3-instrument core (engine / map / ui) | -| `src/core/instrument/engine/filter/` | pure per-voice resonant TPT/SVF filter (HP→BP→LP / HP→notch→LP morph, drive stage; no call site yet) | +| `src/core/instrument/engine/filter/` | pure per-voice resonant TPT/SVF filter (HP→BP→LP / HP→notch→LP morph, drive stage), run by each `Voice` between the pitch and amp stages | | `src/core/json/` | the hand-rolled JSON lexical layer | | `src/core/model/` | the pure bank/sample index and its multi-bank container | | `src/core/reclaim/` | pure prune orphan computation | diff --git a/src/core/instrument/CLAUDE.md b/src/core/instrument/CLAUDE.md index 09a390c..83ec8fb 100644 --- a/src/core/instrument/CLAUDE.md +++ b/src/core/instrument/CLAUDE.md @@ -198,9 +198,9 @@ slider couldn't. Two pure modules split the forward (draw) and inverse (edit) ma ### `engine/` - The engine is the `sampler_core` CMake target over FOUR headers and TWO TUs, split on its own responsibility seam — cold note routing vs the hot per-sample render: - - `play_params.h` — the value layer: `PlayParams`/`AdsrParams`/`TriggerParams`/`PitchEnvParams`, the per-instance mode enums (`ChannelMode`/`VoiceMode`/`MonoTrigger`), and `SampleData` (the ONE loaded capture: decoded PCM + root + loop + start + keyTrack + velocity curve + play params). Shared by the engine, the codec, and the editor, so a UI/codec TU reading a param struct doesn't recompile when a `Voice` member changes. - - `envelopes.h` — the three per-frame evaluators (`AdsrEnvelope` AHDSR, `TriggerEnvelope` fade shape, `PitchEnvelope` AD offset), CONCRETE and fully header-inline. Never give them a common base or a virtual `tick()`: they are called per-voice-per-sample. - - `voice.h` / `voice.cpp` — one voice. The per-SAMPLE render half (`advanceFrame` and everything it calls) is INLINE IN THE HEADER by RT constraint; the per-NOTE half (note-on setup incl. the Preserve ring prime, legato retune, gate-off, the off-thread shifter presize) is out of line in the TU. + - `play_params.h` — the value layer: `PlayParams`/`AdsrParams`/`TriggerParams`/`PitchEnvParams`/`FilterParams`, the per-instance mode enums (`ChannelMode`/`VoiceMode`/`MonoTrigger`), and `SampleData` (the ONE loaded capture: decoded PCM + root + loop + start + keyTrack + velocity curve + play params). Shared by the engine, the codec, and the editor, so a UI/codec TU reading a param struct doesn't recompile when a `Voice` member changes. `FilterParams` stores the filter module's own `FilterSettings` by value rather than a parallel copy of its normalized positions. + - `envelopes.h` — the three per-frame evaluators (`AdsrEnvelope` AHDSR, `TriggerEnvelope` fade shape, `PitchEnvelope` AD offset), CONCRETE and fully header-inline. Never give them a common base or a virtual `tick()`: they are called per-voice-per-sample. The filter envelope is a SECOND `AdsrEnvelope` instance on the voice, not a fourth class. + - `voice.h` / `voice.cpp` — one voice. The per-SAMPLE render half (`advanceFrame` and everything it calls) is INLINE IN THE HEADER by RT constraint; the per-NOTE half (note-on setup incl. the Preserve ring prime, legato retune, gate-off, the off-thread shifter presize) is out of line in the TU. The voice owns its own `VoiceFilter` and filter envelope, run between the pitch stage and the amp multiply — see `engine/filter/CLAUDE.md`. - `voice_engine.h` / `voice_engine.cpp` — `VoiceEngine`: note routing, bounded-stealing allocation, user-parameterized voice count (1–32, default 16), `VoiceMode` Poly/Mono (last-note held-note stack, `MonoTrigger` Retrigger/Legato), two-tier panic (CC 123 = all-notes-off release, CC 120 = immediate hard-stop including Trigger one-shots), and the block render loops. Preview injects a synthetic note-on at the loaded capture's root note into the main `VoiceEngine` — no dedicated `PreviewCard`; preview obeys polyphony/mono/voice-stealing/envelopes. - `pitch_shift` — hand-rolled **correlation-aligned SOLA** (splice-overlap-add) pitch shifter for the Preserve playback mode: one active read tap chases the write head at the shift ratio; each splice jump is refined by a cross-correlation search so the new read point is waveform-aligned, then old and new taps are crossfaded (raised-cosine, amplitude-complementary). Replaces the prior dual-tap OLA whose fixed half-window tap offset caused anti-phase cancellation on many source frequencies. **GA2:** ring buffer **primed with the actual upcoming source** at note-on (was zero-filled) → gap-free frame-0 onset, ~25 ms Preserve onset latency eliminated (Preserve now speaks on frame 0, matching Varispeed), and real-content-bounded tail (last-window tail-truncation gone). No third-party dependencies; RT-discipline: no allocation in `process()`. - `velocity_curve` — pure velocity→amp transfer curve: `VelocityCurve` evaluated by a Fritsch–Carlson monotone cubic Hermite spline (no overshoot outside [0,1]). `eval(velocity)` called once per note-on. `flat()` default (y=1, every velocity→unity) replaces the prior fixed `velocity/127` path — a deliberate non-back-compat behavior change (Daniel-approved). @@ -209,7 +209,7 @@ slider couldn't. Two pure modules split the forward (draw) and inverse (edit) ma ### `map/` - `sample_map` — the bank blob → selected capture resolve, the channel policy (downmix / dual-mono / L-R split), `InstrumentParams` (the ONE parameter set: root/loop/start overrides, keyTrack, velocity curve, `PlaySeconds`), the single override-beats-intrinsic fold (`resolveCapture`, shared by the bank and refs paths so they cannot drift), and the `SampleData` build. **Wall-clock times stored as rate-free SECONDS, resolved against the live project rate — NO hardcoded sample rates in `src/`** (Daniel's standing ruling, load-bearing). Deliberately does NOT link the voice engine: the build's product is plain `SampleData`. -- `component_state_io` (`core/instrument/map`) — the `ComponentState` envelope + params-payload binary codec (envelope v1…v11, params payload v1…v8), split out of `sample_map` (Q-W2v, T4-13 ≡ T2-07) so BOTH artifacts can link the codec without the extension pulling in the whole voice engine to serialize one preset blob — the extension's `instrument_drop` and the instrument's processor read/write the identical bytes, so the cross-artifact contract cannot drift. Payload v1…v7 are the RETIRED per-zone lists: still read, lifting by adopting zone one's capture + parameters (that first zone is what the old first-match resolve actually played, so it is also what supersedes the envelope's stored selection id). +- `component_state_io` (`core/instrument/map`) — the `ComponentState` envelope + params-payload binary codec (envelope v1…v11, params payload v1…v9), split out of `sample_map` (Q-W2v, T4-13 ≡ T2-07) so BOTH artifacts can link the codec without the extension pulling in the whole voice engine to serialize one preset blob — the extension's `instrument_drop` and the instrument's processor read/write the identical bytes, so the cross-artifact contract cannot drift. Payload v1…v7 are the RETIRED per-zone lists: still read, lifting by adopting zone one's capture + parameters (that first zone is what the old first-match resolve actually played, so it is also what supersedes the envelope's stored selection id). Payload v9 appends the per-voice filter tail; a v8 blob is a strict prefix of it and lifts to the off/neutral filter default. - `bank_sync` — generation change-detection + assignment-request consume: owns the yes/no decision logic so the rules are provable without a host. The processor shell owns cadence and side effects. - `bridge_marshal` — pure marshalling helper for the REAPER VST-host bridge read: interprets the `GetProjExtState` int return against its filled buffer. - `trigger_seam` — pure Trigger frames↔fraction converter: owns the shared formula for converting between engine source-frame fade counts and the overlay's fractional representation, threading `startFrame` correctly through pack and unpack directions. @@ -227,6 +227,7 @@ slider couldn't. Two pure modules split the forward (draw) and inverse (edit) ma - `param_slider` — parameter control-panel: vertical stack of TOGGLE (two-segment selector) and SLIDER (horizontal track) rows; maps normalized value to/from handle pixel. - `embed_strip` — compact single-row control layout for embed mode in the track FX chain. - `knob_deck` — pure knob-deck layout + hit-test (FB1): group-box / caption-row / compact-toggle / knob-cell geometry, deterministic whole-group wrap, `DeckLayout` / `DeckHit`. Mirror of `action_bar`/`param_slider`; no LICE or REAPER types. +- `deck_groups` — WHICH groups the Sample face's deck carries, split from `knob_deck`'s HOW they lay out: the `DeckParam` control-id space (the editor's `ParamControl` is an alias of it), the `DeckGroupId` list, `sampleDeckGroups` in signal-flow order (**pitch → filter → amp**, then voice/master), and the deck's bipolar-knob law. Reads `PlayMode` for the AMP group's Gate/Trigger face, which is why this and not `knob_deck` is the module that touches the engine's value layer. - `curve_popup` — pure curve-popup geometry + dismissal test (FB1): centered sheet over the Sample face — width/height clamps, title row, Close button rect, curve-box rect, outside-sheet dismissal test. Mirror of `overflow_menu`; no LICE or REAPER types. - `envelope_overlay` — pure amp-envelope→polyline geometry for the Sample-view envelope overlay (read from `envelope_overlay.h`): maps Gate's AHDSR shape or Trigger's fade-in/unity/%-length/fade-out shape to a polyline inside a rect at the shared time base (Gate: a bounded param-domain schematic, sample-length-free; Trigger: PCM-aligned wall-clock), every vertex clamped in-canvas (`x`/`y` inside the rect). Shares the `EnvNode`/`AmpEnvelope`/`timeToX`/`levelToY` vocabulary with `envelope_edit` so the drawn handle and its grab region agree pixel-for-pixel. No VST3/REAPER/LICE types at the boundary. - `envelope_edit` — pure node hit-test + pixel-delta→clamped-param inverse map for the draggable envelope nodes (read from `envelope_edit.h`): `nodeAtPoint` resolves a grab to the nearest node within a pick radius (Chebyshev distance, draw-order tie-break); `resolveNodeDrag` maps a pixel delta since grab to a new `AmpEnvelope`, enforcing monotonic-in-time ordering between neighbouring nodes and the same caller-supplied per-param clamp bounds the sliders use — a drag can never produce a param a slider couldn't. Mirror of `card_drag`/`waveform_view`; the inverse of `envelope_overlay`'s params→polyline forward map, so node-drag and slider-edit read/write one shared model and can never diverge. diff --git a/src/core/instrument/engine/CMakeLists.txt b/src/core/instrument/engine/CMakeLists.txt index 747f4c2..7f1d2e4 100644 --- a/src/core/instrument/engine/CMakeLists.txt +++ b/src/core/instrument/engine/CMakeLists.txt @@ -13,14 +13,19 @@ reasampler_test(velocity_curve LINK velocity_curve) reasampler_pure_library(master_gain SOURCES master_gain.cpp) reasampler_test(master_gain LINK master_gain) +# Declared before sampler_core because the voice now runs one per sounding note. +add_subdirectory(filter) + # Two TUs on the engine's own responsibility seam (per-note setup vs. note routing and # block render). The per-sample render half stays inline in voice.h precisely so this TU # boundary costs the hot path nothing. reasampler_pure_library(sampler_core SOURCES voice.cpp voice_engine.cpp - LINK PUBLIC peaks pitch_shift velocity_curve) + LINK PUBLIC peaks pitch_shift velocity_curve filter) # Links only sampler_core: linking more would break the plain-data-boundary proof — a VST3 # or REAPER type reaching the core would fail to compile or link here. reasampler_test(sampler_core LINK sampler_core) -add_subdirectory(filter) +# The filter's own seams are covered by the four targets in filter/; this one covers the +# integration: pipeline order, per-voice independence, and the off-by-default bit-identity. +reasampler_test(sampler_filter LINK sampler_core) diff --git a/src/core/instrument/engine/filter/CLAUDE.md b/src/core/instrument/engine/filter/CLAUDE.md index d4577eb..607e13f 100644 --- a/src/core/instrument/engine/filter/CLAUDE.md +++ b/src/core/instrument/engine/filter/CLAUDE.md @@ -6,10 +6,10 @@ The pure per-voice filter a sounding voice runs: a Zavalishin TPT/SVF with a con morph under one of two laws — HP→BP→LP or HP→notch→LP — and a drive stage. No REAPER, no VST3, no allocation, no I/O. Everything here lives in `reasampler::instrument::engine::filter`, nested per the -directory-mirrors-namespace convention — this keeps `FilterSettings` and friends out of -`reasampler::instrument::engine` proper, where `zone_params.h` lives, since this module has -no call site yet to force a collision into the open at compile time. Five files, one -responsibility each: +directory-mirrors-namespace convention, which keeps `FilterSettings` and friends out of +`reasampler::instrument::engine` proper where `play_params.h` lives — and `play_params.h` +now stores a `FilterSettings` by value, so that separation is load-bearing rather than +merely tidy. Five files, one responsibility each: - `filter_params` — the control domain: normalized [0,1] knob position → cutoff Hz, Q, and drive depth, plus the exact inverses for cutoff and Q. @@ -224,8 +224,10 @@ topology. band-pass, `HighNotchLow` on pure high-pass, since it has no band tap to land on. - **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. -- **No call site yet.** Wiring the filter into the voice path is a separate track; nothing - in `sampler_core` references this module today. +- **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`). - **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/play_params.h b/src/core/instrument/engine/play_params.h index f447ad7..2ce2ad0 100644 --- a/src/core/instrument/engine/play_params.h +++ b/src/core/instrument/engine/play_params.h @@ -9,6 +9,7 @@ #include #include "core/audio/peaks.h" +#include "core/instrument/engine/filter/voice_filter.h" #include "core/instrument/engine/velocity_curve.h" namespace reasampler { @@ -90,15 +91,38 @@ struct PitchEnvParams { double peakSemitones = 0.0; // signed depth at the peak }; +// Per-voice resonant filter, off by default (enabled=false -> the render path skips it +// entirely -> bit-identical to the un-filtered engine). Holds the filter module's OWN +// 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. +struct FilterParams { + bool enabled = false; + instrument::engine::filter::FilterSettings settings; + double modAmount = 0.0; // bipolar [-1,+1], envelope -> cutoff + double velAmount = 0.0; // bipolar [-1,+1], velocity -> cutoff + double keyTrack = 0.0; // octaves of cutoff per octave of (note - root) + 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. + VelocityCurve velocityCurve = VelocityCurve::linear(); +}; + // Bundle a voice reads at start(). Defaults reproduce the bare engine (Gate, hold-0 AHDSR, -// Varispeed, pitch envelope off) — core regression tests rely on this; the Preserve product -// default is layered on at (de)serialization, see kDefaultPitchEngine. +// Varispeed, pitch envelope off, filter off) — core regression tests rely on this; the +// Preserve product default is layered on at (de)serialization, see kDefaultPitchEngine. struct PlayParams { PlayMode playMode = PlayMode::Gate; AdsrParams adsr; TriggerParams trigger; PitchEngine pitchEngine = PitchEngine::Varispeed; PitchEnvParams pitchEnv; + FilterParams filter; }; // [start, end) frames, half-open. A zero-length loop (start == end) is the "no sustain loop" diff --git a/src/core/instrument/engine/voice.cpp b/src/core/instrument/engine/voice.cpp index 18560af..d755b82 100644 --- a/src/core/instrument/engine/voice.cpp +++ b/src/core/instrument/engine/voice.cpp @@ -92,6 +92,24 @@ 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 + // 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; + filterVelOffset_ = + p.filter.velAmount * p.filter.velocityCurve.eval(static_cast(velocity)); + filterRate_ = static_cast(sample.sampleRate); + filterEnv_.configure(p.filter.env); + filterEnv_.noteOn(); + filter_.reset(); + updateFilterCutoffBase(note); + } + // Prime the already-sized per-channel shifters with the first window of the actual // upcoming source stream (loop-unrolled under the sustain-loop wrap rule; silence past // the sample end, since that silence is the true stream there). The tap parks on source @@ -162,6 +180,9 @@ void Voice::retune(int note) { if (!active_ || sample_ == nullptr) return; note_ = note; baseRatio_ = keyTrackedRatio(note, sample_->rootNote, sample_->keyTrack); + // Filter key-tracking follows the pitch: it is a function of the note, so a slide moves it + // too. The velocity offset deliberately stays the first note's, matching velocityGain_. + if (filterOn_) updateFilterCutoffBase(note); } void Voice::release() { @@ -169,6 +190,7 @@ void Voice::release() { if (playMode_ == PlayMode::Trigger) return; // Trigger ignores note-off, plays through releasing_ = true; env_.noteOff(); + filterEnv_.noteOff(); } } // namespace reasampler diff --git a/src/core/instrument/engine/voice.h b/src/core/instrument/engine/voice.h index c8e857e..e92b6ff 100644 --- a/src/core/instrument/engine/voice.h +++ b/src/core/instrument/engine/voice.h @@ -13,6 +13,8 @@ #include "core/audio/peaks.h" #include "core/instrument/engine/envelopes.h" +#include "core/instrument/engine/filter/filter_params.h" +#include "core/instrument/engine/filter/voice_filter.h" #include "core/instrument/engine/pitch_shift.h" #include "core/instrument/engine/play_params.h" #include "core/instrument/engine/velocity_curve.h" @@ -40,6 +42,22 @@ inline double keyTrackedRatio(int note, int rootNote, double keyTrack) { return std::pow(2.0, semis / 12.0); } +// One octave expressed in the cutoff control's normalized domain, read out of the filter +// module's OWN inverse rather than re-derived from its endpoints — the log law belongs to +// filter_params, and a second copy here could drift from it. Evaluated at note-on only. +inline double filterNormPerOctave() { + namespace flt = instrument::engine::filter; + return static_cast(flt::filterNormFromCutoffHz(2.0f * flt::kFilterCutoffMinHz) - + 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, and it collapses the solve to nothing across a static +// envelope stage: an unmodulated voice pays one integer compare per frame. +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 @@ -159,6 +177,37 @@ 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). prepare() deliberately + // preserves integrator state, so a moving cutoff glides rather than clicking. + void tickFilterCutoff() { + 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_); + } + + // The cutoff position before the envelope: the stored knob position plus this note's + // velocity offset and key-tracking. Recomputed at note-on and at a legato retune (both + // move the note), never per frame. + void updateFilterCutoffBase(int note) { + double base = filterCutoffNorm_ + filterVelOffset_; + if (filterKeyTrack_ != 0.0 && sample_ != nullptr) { + base += filterKeyTrack_ * + (static_cast(note - sample_->rootNote) / 12.0) * + filterNormPerOctave(); + } + 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 + } + // Seeds the takeover compensation on the first frame after a restart: the ramp is the // actual discontinuity — (pre-cut reference - the new voice's raw output this frame) — // applied ungated so the boundary frame reproduces the old level exactly. @@ -252,6 +301,9 @@ private: const double envFactor = (pitchEnvSemis == 0.0) ? 1.0 : std::pow(2.0, pitchEnvSemis / 12.0); + // Both pitch branches leave the UNENVELOPED post-pitch signal here; the filter acts on + // it and the amp gain is applied afterwards, so the pipeline is pitch -> filter -> amp + // and the amp envelope shapes the filtered result (drive included). double outL, outRlocal = 0.0; if (pitchEngine_ == PitchEngine::Preserve && shiftL_.configured()) { // Feed the shifters the source stream at unity rate (duration held) and transpose @@ -282,7 +334,7 @@ private: const double shift = baseRatio_ * envFactor; shiftL_.setShiftRatio(shift); const double shiftedL = static_cast(shiftL_.process(feedL)); - outL = shiftedL * gain; + outL = shiftedL; if (stereo) { if (haveR && shiftR_.configured()) { // Genuine stereo (linked lag): channel 1's shifter FOLLOWS channel 0's @@ -300,13 +352,12 @@ private: feedOk ? pcmR[static_cast(feedPos_)] : 0.0f; shiftR_.setShiftRatio(shift); outRlocal = - static_cast(shiftR_.processLinked(feedR, shiftL_.lastSplice())) * - gain; + static_cast(shiftR_.processLinked(feedR, shiftL_.lastSplice())); } else { // Mono sample in stereo mode (dual-mono): shiftL_ already produced the // shifted value from the mono feed; mirror it to R. Do NOT call // shiftL_.process again this frame. - outRlocal = shiftedL * gain; + outRlocal = shiftedL; } } ++feedPos_; @@ -330,16 +381,34 @@ private: const double srcL = (i0ok ? static_cast(pcm[i0]) : 0.0) + ((i1ok ? static_cast(pcm[i1]) : 0.0) - (i0ok ? static_cast(pcm[i0]) : 0.0)) * frac; - outL = srcL * gain; + outL = srcL; if (stereo) { const double srcR = (i0ok ? static_cast(pcmR[i0]) : 0.0) + ((i1ok ? static_cast(pcmR[i1]) : 0.0) - (i0ok ? static_cast(pcmR[i0]) : 0.0)) * frac; - outRlocal = srcR * gain; + outRlocal = srcR; } ratio_ = baseRatio_ * envFactor; } + // Skipped whole when disengaged (the default), so an un-filtered render stays + // bit-identical to the pre-filter engine. + if (filterOn_) { + tickFilterCutoff(); + outL = static_cast(filter_.process(0, static_cast(outL))); + // Dual-mono feeds channel 1 the value channel 0 already carried, so mirroring the + // filtered result is exactly what a second identical filter would produce — one + // less kernel pass per frame for the same samples. + if (stereo) { + outRlocal = haveR + ? static_cast(filter_.process(1, static_cast(outRlocal))) + : outL; + } + } + + outL *= gain; + if (stereo) outRlocal *= gain; + // Takeover declick (bounded-blend revision): on the FIRST frame after a takeover/steal // restart, seed the blend weight at 1.0 so this frame's output is // outₙ*(1−w) + ref*w = out*(1−1) + ref*1 = ref (exact boundary identity). @@ -401,6 +470,23 @@ private: std::int64_t playEnd_ = 0; // Trigger: source-frame end; Gate: unused 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. + 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 + // 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/map/component_state_io.cpp b/src/core/instrument/map/component_state_io.cpp index 7f2eaf9..a03848e 100644 --- a/src/core/instrument/map/component_state_io.cpp +++ b/src/core/instrument/map/component_state_io.cpp @@ -1,5 +1,5 @@ // component_state_io — the ComponentState envelope + params-payload binary codec. See -// component_state_io.h for the format ladders (envelope v1..v11, params payload v1..v8). +// component_state_io.h for the format ladders (envelope v1..v11, params payload v1..v9). // Every wire format is FROZEN — byte-identical across revisions. #include "core/instrument/map/component_state_io.h" @@ -51,6 +51,17 @@ void putOverrides(std::vector& out, const InstrumentParams& p) { if (p.startPoint) putLE(out, asU64(*p.startPoint)); } +// A velocity curve: 4-byte LE control-point count, then per point velocity + amp as doubles. +// The amp curve (v7) and the filter's own curve (v9) share this shape. +void putCurve(std::vector& out, const VelocityCurve& curve) { + const std::vector& pts = curve.points(); + putLE(out, static_cast(pts.size())); + for (const VelocityPoint& pt : pts) { + putLE(out, doubleToBits(pt.velocity)); + putLE(out, doubleToBits(pt.amp)); + } +} + // Append the params payload: marker + version + the single parameter record. Always emits // the CURRENT payload version; the marker precedes the record so any reader detects the // shape independent of the envelope version (see component_state_io.h). @@ -79,14 +90,27 @@ void putParamsPayload(std::vector& out, const InstrumentParams& p) putLE(out, doubleToBits(pp.adsr.releaseSeconds)); // Key-tracking scalar (1.0 = 100% ET). putLE(out, doubleToBits(p.keyTrack)); - // The velocity->amp transfer curve, appended last: 4-byte LE control-point count, then - // per point velocity + amp as doubles (endpoints included, so N >= 2). - const std::vector& pts = p.velocityCurve.points(); - putLE(out, static_cast(pts.size())); - for (const VelocityPoint& pt : pts) { - putLE(out, doubleToBits(pt.velocity)); - putLE(out, doubleToBits(pt.amp)); - } + // The velocity->amp transfer curve: 4-byte LE control-point count, then per point + // velocity + amp as doubles (endpoints included, so N >= 2). + putCurve(out, p.velocityCurve); + // v9: the per-voice filter tail. The module's floats widen to doubles on the wire so the + // whole payload stays one numeric shape. + const FilterSeconds& f = pp.filter; + out.push_back(f.enabled ? 1 : 0); + putLE(out, doubleToBits(static_cast(f.settings.cutoffNorm))); + putLE(out, doubleToBits(static_cast(f.settings.resonanceNorm))); + putLE(out, doubleToBits(static_cast(f.settings.morphNorm))); + putLE(out, doubleToBits(static_cast(f.settings.driveNorm))); + out.push_back(f.settings.morphLaw == engine::filter::MorphLaw::HighNotchLow ? 1 : 0); + putLE(out, doubleToBits(f.modAmount)); + putLE(out, doubleToBits(f.velAmount)); + putLE(out, doubleToBits(f.keyTrack)); + putLE(out, doubleToBits(f.env.attackSeconds)); + putLE(out, doubleToBits(f.env.holdSeconds)); + putLE(out, doubleToBits(f.env.decaySeconds)); + putLE(out, doubleToBits(f.env.sustainLevel)); + putLE(out, doubleToBits(f.env.releaseSeconds)); + putCurve(out, f.velocityCurve); } // Read the play tail (v5 shape onward) into `p`. Shared by the legacy zone reader and the @@ -108,9 +132,9 @@ void readSecondsPlayTail(ByteReader& r, InstrumentParams& p) { p.play.adsr.releaseSeconds = bitsToDouble(r.u64()); } -// Read the velocity->amp curve tail into `p`. fromPoints repairs the X-order/endpoint -// invariant defensively; a truncated read leaves the flat default. -void readCurveTail(ByteReader& r, InstrumentParams& p) { +// Read a velocity curve tail into `curve`. fromPoints repairs the X-order/endpoint invariant +// defensively; a truncated read leaves `curve` at whatever default it came in with. +void readCurveTail(ByteReader& r, VelocityCurve& curve) { const std::uint32_t ptCount = r.u32(); std::vector pts; // Bound the reserve to what the blob can hold (16 bytes/point) so a corrupt huge count @@ -123,10 +147,32 @@ void readCurveTail(ByteReader& r, InstrumentParams& p) { pts.push_back(VelocityPoint{vel, amp}); } if (r.ok) { - p.velocityCurve = reasampler::instrument::engine::VelocityCurve::fromPoints(std::move(pts)); + curve = reasampler::instrument::engine::VelocityCurve::fromPoints(std::move(pts)); } } +// Read the v9 filter tail into `p`. A blob that stops short leaves the off/neutral default, +// which is what makes a v8 blob play bit-identically under the new codec. +void readFilterTail(ByteReader& r, InstrumentParams& p) { + FilterSeconds& f = p.play.filter; + f.enabled = (r.u8() != 0); + f.settings.cutoffNorm = static_cast(bitsToDouble(r.u64())); + f.settings.resonanceNorm = static_cast(bitsToDouble(r.u64())); + f.settings.morphNorm = static_cast(bitsToDouble(r.u64())); + f.settings.driveNorm = static_cast(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()); + f.env.attackSeconds = bitsToDouble(r.u64()); + f.env.holdSeconds = bitsToDouble(r.u64()); + f.env.decaySeconds = bitsToDouble(r.u64()); + f.env.sustainLevel = bitsToDouble(r.u64()); + f.env.releaseSeconds = bitsToDouble(r.u64()); + readCurveTail(r, f.velocityCurve); +} + // Read a RETIRED zone-list payload (v1..v7) and adopt zone ONE. Every zone is still parsed // so the truncation ladder behaves exactly as it did — a record that fails mid-way stops the // walk — but only the first zone's capture and parameters survive; the rest drop, touching @@ -188,7 +234,7 @@ PayloadRead readLegacyZonePayload(ByteReader& r, std::uint32_t pv, double projec // A pre-v6 payload leaves keyTrack = 1.0 (100% ET), so an already-saved instance // repitches BIT-IDENTICALLY. A pre-v7 payload leaves VelocityCurve::flat(). if (keyTrackTail) p.keyTrack = bitsToDouble(r.u64()); - if (curveTail) readCurveTail(r, p); + if (curveTail) readCurveTail(r, p.velocityCurve); // Payload version 4 (a branch-only frames tail, never shipped) and any unknown pv // leave the seconds product defaults on p.play. if (!r.ok) break; // truncated mid-record -> keep what parsed cleanly, drop the rest @@ -201,15 +247,16 @@ PayloadRead readLegacyZonePayload(ByteReader& r, std::uint32_t pv, double projec return out; } -// Read whichever payload shape follows: the CURRENT v8 single record, or a retired v1..v7 -// zone list (adopting zone one). An absent marker means v1 (a plain small zone count). +// Read whichever payload shape follows: the single-record shape (v8 onward, growing by +// appended tails), or a retired v1..v7 zone list (adopting zone one). An absent marker means +// v1 (a plain small zone count). PayloadRead readParamsPayload(ByteReader& r, double projectRate) { std::uint32_t pv = 0; // 0 = v1, no marker if (r.peekU32() == kParamsFormatMarker) { r.u32(); // consume the marker pv = r.u32(); // payload version } - if (pv < kParamsPayloadVersion) return readLegacyZonePayload(r, pv, projectRate); + if (pv < kParamsSingleRecordVersion) return readLegacyZonePayload(r, pv, projectRate); PayloadRead out; InstrumentParams& p = out.params; @@ -227,7 +274,8 @@ PayloadRead readParamsPayload(ByteReader& r, double projectRate) { if (hasStart) p.startPoint = r.i64(); readSecondsPlayTail(r, p); p.keyTrack = bitsToDouble(r.u64()); - readCurveTail(r, p); + readCurveTail(r, p.velocityCurve); + if (pv >= kParamsFilterVersion) readFilterTail(r, p); // A truncated record leaves whatever parsed plus construction defaults for the rest — // the same degrade-don't-throw contract the zone ladder always had. if (!r.ok) return PayloadRead{}; diff --git a/src/core/instrument/map/component_state_io.h b/src/core/instrument/map/component_state_io.h index 80cad43..141968e 100644 --- a/src/core/instrument/map/component_state_io.h +++ b/src/core/instrument/map/component_state_io.h @@ -63,12 +63,20 @@ namespace reasampler::instrument::map { // payload lifts to VelocityCurve::flat() — a DELIBERATE non-back-compat behavior change // (soft hits play louder than under the old linear velocity/127 map). // -// v8 (CURRENT WRITE FORMAT) is the one-parameter-set record: marker + version (== 8), then a -// SINGLE record with no count, no key range and no sample id (the envelope's selection id is -// the capture): 1 byte hasRootOverride + 4-byte LE rootOverride (iff set); 1 byte -// hasLoopOverride + [1 byte loop.hasLoop + 8-byte LE loop.start + loop.end] (iff set); -// 1 byte hasStartPoint + 8-byte LE startPoint (iff set); the v5 play tail verbatim -// (SECONDS); 8-byte LE keyTrack; then the velocity curve (count + points) as in v7. +// v8 is the first one-parameter-set record: marker + version (== 8), then a SINGLE record +// with no count, no key range and no sample id (the envelope's selection id is the capture): +// 1 byte hasRootOverride + 4-byte LE rootOverride (iff set); 1 byte hasLoopOverride + [1 byte +// loop.hasLoop + 8-byte LE loop.start + loop.end] (iff set); 1 byte hasStartPoint + 8-byte LE +// startPoint (iff set); the v5 play tail verbatim (SECONDS); 8-byte LE keyTrack; then the +// velocity curve (count + points) as in v7. +// +// v9 (CURRENT WRITE FORMAT) is v8 PLUS the per-voice filter tail, appended after the velocity +// curve: 1 byte enabled; 8-byte LE cutoffNorm, resonanceNorm, morphNorm, driveNorm (doubles, +// widened from the module's floats); 1 byte morphLaw (0 HighBandLow / 1 HighNotchLow); 8-byte +// LE modAmount, velAmount, keyTrack; 8-byte LE filter-env attack/hold/decay/sustain/release +// SECONDS; then the filter's OWN velocity curve (count + points, same shape as v7's). A v8 +// blob is a strict prefix, so it lifts to the off/neutral filter default and plays +// bit-identically. // // A truncated/unknown/empty payload yields the DEFAULT parameter set. @@ -77,9 +85,19 @@ inline constexpr std::uint32_t kPerformanceStateVersion = 2; // The params-payload format version and its detection marker. The marker is a high sentinel // no legitimate v1 zone count (bounded by 128 MIDI zones, always tiny) could ever equal, so // a reader detects record shape independent of the envelope version. -inline constexpr std::uint32_t kParamsPayloadVersion = 8; // one parameter set, no zones +inline constexpr std::uint32_t kParamsPayloadVersion = 9; // v8 + the per-voice filter tail inline constexpr std::uint32_t kParamsFormatMarker = 0xFFFFFF00u; +// The first SINGLE-RECORD payload version. Everything below it is a retired zone list and +// reads through the legacy walk; everything at or above it shares the v8 record shape and +// grows by appending. The reader branches on this, never on kParamsPayloadVersion, so a +// future bump does not silently push the previous format back into the zone reader. +inline constexpr std::uint32_t kParamsSingleRecordVersion = 8; + +// v8 + the per-voice filter tail. Named so the filter branch in readParamsPayload is +// self-describing, mirroring the envelope's version constants. +inline constexpr std::uint32_t kParamsFilterVersion = 9; + // (No nominal-rate constant.) The legacy v3 payload's wall-clock frame counts convert to // seconds at the v3 read boundary using the PROJECT sample rate threaded in as a parameter // (frames / projectRate = seconds) — the same rate the build already receives, so the diff --git a/src/core/instrument/map/sample_map.cpp b/src/core/instrument/map/sample_map.cpp index 0e034c6..7c39102 100644 --- a/src/core/instrument/map/sample_map.cpp +++ b/src/core/instrument/map/sample_map.cpp @@ -225,6 +225,19 @@ PlayParams resolvePlay(const PlaySeconds& stored, int sampleRate) { out.pitchEnv.attackFrames = secToFrames(stored.pitchEnv.attackSeconds); out.pitchEnv.decayFrames = secToFrames(stored.pitchEnv.decaySeconds); out.pitchEnv.peakSemitones = stored.pitchEnv.peakSemitones; // depth, not a time + // Filter: the control positions are already rate-free and carry through untouched; only + // its envelope resolves to frames. + out.filter.enabled = stored.filter.enabled; + out.filter.settings = stored.filter.settings; + out.filter.modAmount = stored.filter.modAmount; + out.filter.velAmount = stored.filter.velAmount; + out.filter.keyTrack = stored.filter.keyTrack; + out.filter.velocityCurve = stored.filter.velocityCurve; + out.filter.env.attackFrames = secToFrames(stored.filter.env.attackSeconds); + out.filter.env.holdFrames = secToFrames(stored.filter.env.holdSeconds); + out.filter.env.decayFrames = secToFrames(stored.filter.env.decaySeconds); + out.filter.env.sustainLevel = stored.filter.env.sustainLevel; + out.filter.env.releaseFrames = secToFrames(stored.filter.env.releaseSeconds); return out; } diff --git a/src/core/instrument/map/sample_map.h b/src/core/instrument/map/sample_map.h index 3020949..9b78799 100644 --- a/src/core/instrument/map/sample_map.h +++ b/src/core/instrument/map/sample_map.h @@ -160,6 +160,21 @@ struct PitchEnvSeconds { double peakSemitones = 0.0; // signed depth at the peak }; +// The stored mirror of the engine's FilterParams (play_params.h, which owns what each field +// MEANS). Only the envelope differs between the two: the control positions and depths are +// rate-free already, so this block is a seconds/frames split of one field, not of the whole +// struct. The env default is a flat unity, so `enabled` is the only thing standing between a +// loaded blob and the pre-filter sound. +struct FilterSeconds { + bool enabled = false; + engine::filter::FilterSettings settings; + double modAmount = 0.0; + double velAmount = 0.0; + double keyTrack = 0.0; + AdsrSeconds env{0.0, 0.0, 0.0, 1.0, 0.0}; + VelocityCurve velocityCurve = VelocityCurve::linear(); +}; + // The stored play bundle: wall-clock times in SECONDS, source-timeline quantities in // frames/fractions (TriggerParams). Instrument-owned, serialized, editor-facing — distinct // from the engine-facing PlayParams (frames). @@ -169,6 +184,7 @@ struct PlaySeconds { TriggerParams trigger; // Trigger: %-length + fades (source frames) PitchEngine pitchEngine = kDefaultPitchEngine; // product default: Preserve PitchEnvSeconds pitchEnv; // AD pitch modulation (seconds), off by default + FilterSeconds filter; // per-voice filter, off by default }; // Resolve a stored seconds bundle to the engine's frame-domain PlayParams against a live diff --git a/src/core/instrument/ui/CMakeLists.txt b/src/core/instrument/ui/CMakeLists.txt index bda722a..b267d41 100644 --- a/src/core/instrument/ui/CMakeLists.txt +++ b/src/core/instrument/ui/CMakeLists.txt @@ -44,5 +44,12 @@ 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). +reasampler_pure_library(deck_groups + SOURCES deck_groups.cpp + LINK PUBLIC knob_deck velocity_curve peaks) +reasampler_test(deck_groups LINK deck_groups) + 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/deck_groups.cpp b/src/core/instrument/ui/deck_groups.cpp new file mode 100644 index 0000000..57d8a41 --- /dev/null +++ b/src/core/instrument/ui/deck_groups.cpp @@ -0,0 +1,100 @@ +// deck_groups.cpp — see deck_groups.h. Pure data; no host types. + +#include "core/instrument/ui/deck_groups.h" + +#include + +namespace reasampler::instrument::ui { + +namespace { +int id(DeckParam p) { return static_cast(p); } +double clamp(double v, double lo, double hi) { return v < lo ? lo : (v > hi ? hi : v); } +} // namespace + +double deckBipolarFromNorm(double norm) { return clamp(norm, 0.0, 1.0) * 2.0 - 1.0; } +double deckNormFromBipolar(double value) { return clamp(value, -1.0, 1.0) * 0.5 + 0.5; } + +std::vector sampleDeckGroups(PlayMode playMode) { + std::vector out; + { + DeckGroupDesc pitch; + pitch.id = kGroupPitch; + pitch.captionWidth = 38; + pitch.captionToggle = {id(DeckParam::kPitchEngine), 48}; + pitch.cellIds = {id(DeckParam::kKeyTrack)}; + out.push_back(std::move(pitch)); + } + { + DeckGroupDesc penv; + penv.id = kGroupPitchEnv; + penv.captionWidth = 58; + penv.captionToggle = {id(DeckParam::kPitchEnvEnable), 32}; + penv.cellIds = {id(DeckParam::kPitchEnvAttack), + id(DeckParam::kPitchEnvDecay), + id(DeckParam::kPitchEnvDepth)}; + out.push_back(std::move(penv)); + } + { + // Tone shaping left-to-right, then the three modulation depths that all target cutoff. + DeckGroupDesc filter; + filter.id = kGroupFilter; + filter.captionWidth = 46; + filter.captionToggle = {id(DeckParam::kFilterEnable), 32}; + filter.cellIds = {id(DeckParam::kFilterMorph), + id(DeckParam::kFilterCutoff), + id(DeckParam::kFilterQ), + id(DeckParam::kFilterDrive), + id(DeckParam::kFilterModAmt), + id(DeckParam::kFilterVel), + id(DeckParam::kFilterKeyTrack)}; + filter.rowToggle = {id(DeckParam::kFilterLaw), 44}; + out.push_back(std::move(filter)); + } + { + DeckGroupDesc fenv; + fenv.id = kGroupFilterEnv; + fenv.captionWidth = 66; + fenv.cellIds = {id(DeckParam::kFilterEnvAttack), + id(DeckParam::kFilterEnvHold), + id(DeckParam::kFilterEnvDecay), + id(DeckParam::kFilterEnvSustain), + id(DeckParam::kFilterEnvRelease)}; + out.push_back(std::move(fenv)); + } + { + DeckGroupDesc amp; + amp.id = kGroupAmpEnv; + amp.captionWidth = 78; + amp.captionToggle = {id(DeckParam::kPlayMode), 44}; + if (playMode == PlayMode::Gate) { + amp.cellIds = {id(DeckParam::kAttack), id(DeckParam::kHold), + id(DeckParam::kDecay), id(DeckParam::kSustain), + 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. + amp.cellIds = {id(DeckParam::kTrigFadeIn), id(DeckParam::kTrigLength), + id(DeckParam::kTrigFadeOut), -1, -1}; + } + out.push_back(std::move(amp)); + } + { + DeckGroupDesc voice; + voice.id = kGroupVoice; + voice.captionWidth = 38; + voice.captionToggle = {id(DeckParam::kVoiceMode), 40}; + voice.cellIds = {id(DeckParam::kVoiceCount)}; + voice.rowToggle = {id(DeckParam::kMonoTrigger), 44}; + out.push_back(std::move(voice)); + } + { + DeckGroupDesc master; + master.id = kGroupMaster; + master.captionWidth = 46; + master.cellIds = {id(DeckParam::kMasterGain)}; + out.push_back(std::move(master)); + } + return out; +} + +} // namespace reasampler::instrument::ui diff --git a/src/core/instrument/ui/deck_groups.h b/src/core/instrument/ui/deck_groups.h new file mode 100644 index 0000000..1477d9d --- /dev/null +++ b/src/core/instrument/ui/deck_groups.h @@ -0,0 +1,82 @@ +// deck_groups.h — WHICH groups the Sample face's knob deck carries and in what order, plus +// the control-id space they are built from. Pure data: knob_deck lays out whatever descriptors +// it is handed, and this module decides what those descriptors are, so the deck's signal-flow +// ordering is provable without a host. + +#pragma once + +#include + +#include "core/instrument/engine/play_params.h" // PlayMode (the AMP group's Gate/Trigger face) +#include "core/instrument/ui/knob_deck.h" // DeckGroupDesc + +namespace reasampler::instrument::ui { + +// Deck control ids. Opaque to knob_deck, resolved by the shell's hit-test and value binding. +// Runtime-only — nothing persists them, so the ordering here is free to change. +enum class DeckParam { + kPlayMode = 0, // Gate | Trigger toggle + kPitchEngine, // Varispeed | Preserve toggle + kAttack, // AHDSR attack (Gate) / — + kHold, // AHDSR hold (Gate) + kDecay, // AHDSR decay (Gate) + kSustain, // AHDSR sustain (Gate) + kRelease, // AHDSR release (Gate) + kTrigLength, // Trigger %-length + kTrigFadeIn, // Trigger fade-in + kTrigFadeOut, // Trigger fade-out + kPitchEnvEnable, // AD pitch envelope on|off + kPitchEnvAttack, // AD pitch attack + kPitchEnvDecay, // AD pitch decay + kPitchEnvDepth, // AD pitch depth in +/- semitones + kKeyTrack, // key-tracking 0..200% (lives on InstrumentParams, not PlaySeconds) + // Filter. The four control positions map through filter_params' own laws; the three + // depths are bipolar and centred at zero. + kFilterEnable, // filter on|off caption toggle + kFilterMorph, // morph position: high-pass .. low-pass + kFilterCutoff, // cutoff, log across the audio band + kFilterQ, // resonance + kFilterDrive, // in-loop drive depth + kFilterModAmt, // filter envelope -> cutoff, +/-100% + kFilterVel, // velocity -> cutoff, +/-100% + kFilterKeyTrack, // note -> cutoff, 0..200% + kFilterLaw, // morph law row toggle: HP-BP-LP | HP-notch-LP + kFilterEnvAttack, + kFilterEnvHold, + kFilterEnvDecay, + kFilterEnvSustain, + kFilterEnvRelease, + // Deck-only controls: processor-side per-instance params — routed to the processor + // setters, never through the parameter set. + kVoiceCount, // polyphony bound (1..32) — a stepped knob in the VOICE group + kVoiceMode, // Poly | Mono caption toggle (VOICE group) + kMonoTrigger, // Retrig | Legato row toggle (VOICE group; live only in Mono) + kMasterGain, // post-mixer master gain knob (-inf..+24 dB taper, MASTER group) + kCount +}; + +// Deck group ids. Unscoped so the shell's caption switch reads against the plain `id` int +// knob_deck carries. +enum DeckGroupId { + kGroupPitch = 0, + kGroupPitchEnv, + kGroupFilter, + kGroupFilterEnv, + kGroupAmpEnv, + kGroupVoice, + kGroupMaster, +}; + +// 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. +std::vector sampleDeckGroups(PlayMode playMode); + +// The deck's BIPOLAR knob law: 0.5 of the knob's travel is zero depth, the ends are -1 and +// +1. Exact inverses, and exact at the centre detent (0.5 -> 0 -> 0.5), so a knob parked at +// centre can never persist a hair of modulation. Out-of-range norm clamps to the endpoints. +double deckBipolarFromNorm(double norm); +double deckNormFromBipolar(double value); + +} // namespace reasampler::instrument::ui diff --git a/src/shell/instrument/CLAUDE.md b/src/shell/instrument/CLAUDE.md index 2c6d196..75ac742 100644 --- a/src/shell/instrument/CLAUDE.md +++ b/src/shell/instrument/CLAUDE.md @@ -11,7 +11,7 @@ The pure engine/geometry core this shell wraps (`sampler_core`, `pitch_shift`, `sample_map`, `component_state_io`, `play_params.h`, `editor_geometry`, `sample_bands`, `sample_chrome`, `keyboard_strip`, `waveform_view`, `capture_browser`, `browser_scroll`, `param_slider`, `trigger_seam`, `velocity_curve`, `embed_strip`, `knob_deck`, -`curve_popup`, `master_gain`, `reasampler_uid.h`) lives in `core/instrument/*` and +`deck_groups`, `curve_popup`, `master_gain`, `reasampler_uid.h`) lives in `core/instrument/*` and `core/wire` and is documented there — this directory consumes it but does not own it. ## Invariants @@ -90,7 +90,7 @@ scattered `#ifdef`s in the VST shell, except the one described below). - `reasampler_editor` — VST3 `IPlugView` LICE editor shell: hosts a LICE-drawn child window; the Sample face is home and Browse is a modal picker over it. Split on the Sample face's BAND axis, mirroring the pure `sample_bands` allocator: `editor_session` (session/bridge state, caches, commit-and-reload), `editor_controls` (parameter plumbing + the ONE `faceLayout` band resolve every paint and hit-test path shares), then matching paint and input sets — `editor_paint`/`editor_input` (dispatch + drag router + hover dispatch), `_chrome`, `_waveform`, `_deck` — plus the two band-independent surfaces (`_browse` for the modal picker, `_curve` for the velocity-curve popup) and `editor_platform` (IPlugView/Win32 window plumbing). Shared internals in `editor_internal.h`, no TU of its own. Drop-onto-editor ingest is NOT shipped (deferred). - `reasampler_embed` — implements `IReaperUIEmbedInterface` so the instrument draws inline in the TCP/MCP without a plugin-owned HWND; delegates layout to `embed_strip`. A read-only readout: the loaded capture across the keyboard span with its root marked, plus the activity level. It takes no mouse input (there is nothing on the strip to select). - `vst_entry` — VST3 entry point: `GetPluginFactory` export, class registration, channel-forked class UIDs. -- `editor_internal.h` — INTERNAL shared helpers for the `reasampler_editor` TU family, included only by the editor's own shell TUs (`editor_session` / `editor_controls` / `editor_paint_*` / `editor_input_*` / `editor_platform`), never a public seam: the `Rect`↔kit adapters, small draw primitives (knob face / title band), label helpers, deck group ids, and the velocity-curve box derivation — the helpers more than one band TU needs. The piano-strip and root-key draws live in `editor_paint_chrome`, their only consumer, not here. +- `editor_internal.h` — INTERNAL shared helpers for the `reasampler_editor` TU family, included only by the editor's own shell TUs (`editor_session` / `editor_controls` / `editor_paint_*` / `editor_input_*` / `editor_platform`), never a public seam: the `Rect`↔kit adapters, small draw primitives (knob face / title band), label helpers, and the velocity-curve box derivation — the helpers more than one band TU needs. The deck's control ids, group ids and group composition are the pure `deck_groups` module's, not this file's. The piano-strip and root-key draws live in `editor_paint_chrome`, their only consumer, not here. - `reasampler_vst.h` — shared identity constants for the ReaSampler VST3 instrument (Phase S): the plugin's class UID (the channel-selected `Steinberg::FUID`, built from the FOREVER-FROZEN macros in `core/wire/reasampler_uid.h`), vendor name/URL/email, so the processor, factory, and editor agree. A class UID is FOREVER-STABLE once shipped — minted once, never regenerated. *(Newly authored per this dispatch's brief — no existing root-CLAUDE.md bullet; verified by reading `src/shell/instrument/reasampler_vst.h` directly.)* ## Gotchas diff --git a/src/shell/instrument/CMakeLists.txt b/src/shell/instrument/CMakeLists.txt index 791e169..2ebb1c0 100644 --- a/src/shell/instrument/CMakeLists.txt +++ b/src/shell/instrument/CMakeLists.txt @@ -85,7 +85,7 @@ if(WIN32 AND EXISTS "${VST3_SDK}/public.sdk/source/main/pluginfactory.cpp") capture_browser keyboard_strip sample_bands sample_chrome waveform_view bank_sync browser_scroll param_slider tooltip theme component_geometry bank_grid trigger_seam envelope_overlay envelope_edit - knob_deck curve_popup master_gain sample_usage file_bytes) + knob_deck deck_groups curve_popup master_gain sample_usage file_bytes) # SDK_INC gives the REAPER VST3 interfaces + API header for the bridge; WDL_INC gives # LICE for the editor. The VST3 SDK headers arrive via vst3_sdk PUBLIC. target_include_directories(reasampler_vst PRIVATE ${REASAMPLER_SRC_DIR} ${SDK_INC} ${WDL_INC}) diff --git a/src/shell/instrument/editor_controls.cpp b/src/shell/instrument/editor_controls.cpp index 2b5d5e5..c35bfa0 100644 --- a/src/shell/instrument/editor_controls.cpp +++ b/src/shell/instrument/editor_controls.cpp @@ -12,11 +12,13 @@ #include #include +#include "core/instrument/engine/filter/filter_params.h" // the filter's own control laws #include "core/instrument/engine/master_gain.h" // master-gain dB<->linear<->knob taper #include "core/instrument/map/trigger_seam.h" // triggerPlayLength / fade fraction converters +#include "core/instrument/ui/deck_groups.h" // sampleDeckGroups (the deck's composition) #include "core/instrument/ui/knob_deck.h" // deckHeight / kDeckKnobSize (the band's own height) #include "core/util/clamp01.h" -#include "shell/instrument/editor_internal.h" // DeckGroup ids +#include "shell/instrument/editor_internal.h" #include "shell/instrument/reasampler_processor.h" namespace reasampler::vst { @@ -28,9 +30,16 @@ using instrument::ui::chromeRects; using instrument::ui::deckHeight; using instrument::ui::kDeckKnobSize; using instrument::ui::kPad; +using instrument::ui::deckBipolarFromNorm; +using instrument::ui::deckNormFromBipolar; +using instrument::ui::sampleDeckGroups; using instrument::engine::formatMasterGainLabel; using instrument::engine::masterGainLinearFromNorm; using instrument::engine::masterGainNormFromLinear; +using instrument::engine::filter::MorphLaw; +using instrument::engine::filter::filterCutoffHzFromNorm; +using instrument::engine::filter::filterDriveDepthFromNorm; +using instrument::engine::filter::filterQFromNorm; using util::clamp01; namespace { @@ -53,7 +62,7 @@ ReaSamplerEditor::FaceLayout ReaSamplerEditor::faceLayout(int w, int h) const { // chrome interior, and the deck descriptors can never be derived three different ways. // The deck's own wrapped height is the only interior measurement the allocator needs. FaceLayout fl; - fl.deckDescs = deckGroupDescs(params_.play); + fl.deckDescs = sampleDeckGroups(params_.play.playMode); fl.bands = computeSampleBands(w, h, deckHeight(fl.deckDescs, w - 2 * kPad)); fl.chrome = chromeRects(fl.bands.chrome, kDeckKnobSize); return fl; @@ -87,6 +96,23 @@ double ReaSamplerEditor::controlValue(int id, const PlaySeconds& play) const { case ParamControl::kPitchEnvDepth: // Signed depth centered at 0.5 (0.5 == 0 semitones). return clamp01(0.5 + play.pitchEnv.peakSemitones / (2.0 * kPitchDepthMaxSemis)); + // Filter. The four tone controls ARE the module's normalized positions — stored and + // shown as-is, so the knob travel is exactly filter_params' own law. + case ParamControl::kFilterEnable: return play.filter.enabled ? 1.0 : 0.0; + case ParamControl::kFilterLaw: + return play.filter.settings.morphLaw == MorphLaw::HighNotchLow ? 1.0 : 0.0; + case ParamControl::kFilterMorph: return clamp01(play.filter.settings.morphNorm); + case ParamControl::kFilterCutoff: return clamp01(play.filter.settings.cutoffNorm); + case ParamControl::kFilterQ: return clamp01(play.filter.settings.resonanceNorm); + case ParamControl::kFilterDrive: return clamp01(play.filter.settings.driveNorm); + case ParamControl::kFilterModAmt: return deckNormFromBipolar(play.filter.modAmount); + case ParamControl::kFilterVel: return deckNormFromBipolar(play.filter.velAmount); + case ParamControl::kFilterKeyTrack:return clamp01(play.filter.keyTrack / kKeyTrackMax); + case ParamControl::kFilterEnvAttack: return secToNorm(play.filter.env.attackSeconds); + case ParamControl::kFilterEnvHold: return secToNorm(play.filter.env.holdSeconds); + case ParamControl::kFilterEnvDecay: return secToNorm(play.filter.env.decaySeconds); + case ParamControl::kFilterEnvSustain: return clamp01(play.filter.env.sustainLevel); + case ParamControl::kFilterEnvRelease: return secToNorm(play.filter.env.releaseSeconds); default: return 0.0; } } @@ -126,6 +152,33 @@ void ReaSamplerEditor::applyControl(int id, PlaySeconds& play, double value, case ParamControl::kPitchEnvDepth: play.pitchEnv.peakSemitones = (clamp01(value) - 0.5) * 2.0 * kPitchDepthMaxSemis; break; + case ParamControl::kFilterEnable: play.filter.enabled = (segment == 1); break; + case ParamControl::kFilterLaw: + play.filter.settings.morphLaw = + (segment == 1) ? MorphLaw::HighNotchLow : MorphLaw::HighBandLow; + break; + case ParamControl::kFilterMorph: + play.filter.settings.morphNorm = static_cast(clamp01(value)); break; + case ParamControl::kFilterCutoff: + play.filter.settings.cutoffNorm = static_cast(clamp01(value)); break; + case ParamControl::kFilterQ: + play.filter.settings.resonanceNorm = static_cast(clamp01(value)); break; + case ParamControl::kFilterDrive: + play.filter.settings.driveNorm = static_cast(clamp01(value)); break; + case ParamControl::kFilterModAmt: play.filter.modAmount = deckBipolarFromNorm(value); break; + case ParamControl::kFilterVel: play.filter.velAmount = deckBipolarFromNorm(value); break; + case ParamControl::kFilterKeyTrack: + play.filter.keyTrack = clamp01(value) * kKeyTrackMax; break; + case ParamControl::kFilterEnvAttack: + play.filter.env.attackSeconds = normToSec(value); break; + case ParamControl::kFilterEnvHold: + play.filter.env.holdSeconds = normToSec(value); break; + case ParamControl::kFilterEnvDecay: + play.filter.env.decaySeconds = normToSec(value); break; + case ParamControl::kFilterEnvSustain: + play.filter.env.sustainLevel = clamp01(value); break; + case ParamControl::kFilterEnvRelease: + play.filter.env.releaseSeconds = normToSec(value); break; default: break; } } @@ -151,68 +204,6 @@ double ReaSamplerEditor::previewVelocity01() const { return static_cast(processor_->previewVelocity()) / 127.0; } -std::vector ReaSamplerEditor::deckGroupDescs(const PlaySeconds& play) const { - // The deck band's groups, left to right. Group widths are mode-independent: AMP ENVELOPE - // reserves its 5-cell Gate width (Trigger leaves two blank cells), so a Gate<->Trigger - // flip repopulates in place and never reflows the neighbouring groups. - std::vector out; - { - DeckGroupDesc amp; - amp.id = kGroupAmpEnv; - amp.captionWidth = 78; - amp.captionToggle = {static_cast(ParamControl::kPlayMode), 44}; - if (play.playMode == PlayMode::Gate) { - amp.cellIds = {static_cast(ParamControl::kAttack), - static_cast(ParamControl::kHold), - static_cast(ParamControl::kDecay), - static_cast(ParamControl::kSustain), - static_cast(ParamControl::kRelease)}; - } else { - // Trigger, time-ordered left-to-right (Fade In / Length % / Fade Out — matches - // the drawn envelope), plus the two reserved blanks. - amp.cellIds = {static_cast(ParamControl::kTrigFadeIn), - static_cast(ParamControl::kTrigLength), - static_cast(ParamControl::kTrigFadeOut), -1, -1}; - } - out.push_back(std::move(amp)); - } - { - DeckGroupDesc pitch; - pitch.id = kGroupPitch; - pitch.captionWidth = 38; - pitch.captionToggle = {static_cast(ParamControl::kPitchEngine), 48}; - pitch.cellIds = {static_cast(ParamControl::kKeyTrack)}; - out.push_back(std::move(pitch)); - } - { - DeckGroupDesc penv; - penv.id = kGroupPitchEnv; - penv.captionWidth = 58; - penv.captionToggle = {static_cast(ParamControl::kPitchEnvEnable), 32}; - penv.cellIds = {static_cast(ParamControl::kPitchEnvAttack), - static_cast(ParamControl::kPitchEnvDecay), - static_cast(ParamControl::kPitchEnvDepth)}; - out.push_back(std::move(penv)); - } - { - DeckGroupDesc voice; - voice.id = kGroupVoice; - voice.captionWidth = 38; - voice.captionToggle = {static_cast(ParamControl::kVoiceMode), 40}; - voice.cellIds = {static_cast(ParamControl::kVoiceCount)}; - voice.rowToggle = {static_cast(ParamControl::kMonoTrigger), 44}; - out.push_back(std::move(voice)); - } - { - DeckGroupDesc master; - master.id = kGroupMaster; - master.captionWidth = 46; - master.cellIds = {static_cast(ParamControl::kMasterGain)}; - out.push_back(std::move(master)); - } - return out; -} - double ReaSamplerEditor::deckControlNorm(int id) const { if (id == -2) return previewVelocity01(); // the chrome preview-velocity knob switch (static_cast(id)) { @@ -294,6 +285,43 @@ std::string ReaSamplerEditor::deckValueLabel(int id) const { snprintf(buf, sizeof(buf), "%d", voiceCount_); break; case ParamControl::kMasterGain: formatMasterGainLabel(deckControlNorm(id), buf, sizeof(buf)); break; + // Filter readouts run the stored normalized positions back through the module's OWN + // laws, so what the label says is what the kernel is solved for. + case ParamControl::kFilterMorph: { + const double m = play.filter.settings.morphNorm; + snprintf(buf, sizeof(buf), "%.0f%%", m * 100.0); + break; + } + case ParamControl::kFilterCutoff: { + const float hz = filterCutoffHzFromNorm(play.filter.settings.cutoffNorm); + if (hz >= 1000.0f) snprintf(buf, sizeof(buf), "%.2fk", hz / 1000.0f); + else snprintf(buf, sizeof(buf), "%.0fHz", hz); + break; + } + case ParamControl::kFilterQ: + snprintf(buf, sizeof(buf), "%.2f", + static_cast(filterQFromNorm(play.filter.settings.resonanceNorm))); + break; + case ParamControl::kFilterDrive: + snprintf(buf, sizeof(buf), "%.2f", + static_cast(filterDriveDepthFromNorm(play.filter.settings.driveNorm))); + break; + case ParamControl::kFilterModAmt: + snprintf(buf, sizeof(buf), "%+.0f%%", play.filter.modAmount * 100.0); break; + case ParamControl::kFilterVel: + snprintf(buf, sizeof(buf), "%+.0f%%", play.filter.velAmount * 100.0); break; + case ParamControl::kFilterKeyTrack: + snprintf(buf, sizeof(buf), "%.0f%%", play.filter.keyTrack * 100.0); break; + case ParamControl::kFilterEnvAttack: + snprintf(buf, sizeof(buf), "%.3fs", play.filter.env.attackSeconds); break; + case ParamControl::kFilterEnvHold: + snprintf(buf, sizeof(buf), "%.3fs", play.filter.env.holdSeconds); break; + case ParamControl::kFilterEnvDecay: + snprintf(buf, sizeof(buf), "%.3fs", play.filter.env.decaySeconds); break; + case ParamControl::kFilterEnvSustain: + snprintf(buf, sizeof(buf), "%.0f%%", play.filter.env.sustainLevel * 100.0); break; + case ParamControl::kFilterEnvRelease: + snprintf(buf, sizeof(buf), "%.3fs", play.filter.env.releaseSeconds); break; default: // -2 (preview velocity) is labeled at its chrome call site; nothing else here. break; diff --git a/src/shell/instrument/editor_input_deck.cpp b/src/shell/instrument/editor_input_deck.cpp index 5fa1bc8..4c109ce 100644 --- a/src/shell/instrument/editor_input_deck.cpp +++ b/src/shell/instrument/editor_input_deck.cpp @@ -16,6 +16,30 @@ namespace reasampler::vst { using namespace reasampler::ui; using namespace reasampler::instrument::ui; +bool ReaSamplerEditor::deckKnobDisabled(int id) const { + switch (static_cast(id)) { + case ParamControl::kPitchEnvAttack: + case ParamControl::kPitchEnvDecay: + case ParamControl::kPitchEnvDepth: + return !params_.play.pitchEnv.enabled; + case ParamControl::kFilterMorph: + case ParamControl::kFilterCutoff: + case ParamControl::kFilterQ: + case ParamControl::kFilterDrive: + case ParamControl::kFilterModAmt: + case ParamControl::kFilterVel: + case ParamControl::kFilterKeyTrack: + case ParamControl::kFilterEnvAttack: + case ParamControl::kFilterEnvHold: + case ParamControl::kFilterEnvDecay: + case ParamControl::kFilterEnvSustain: + case ParamControl::kFilterEnvRelease: + return !params_.play.filter.enabled; + default: + return false; + } +} + bool ReaSamplerEditor::mouseDownDeck(const FaceLayout& fl, int x, int y) { const Rect& band = fl.bands.decks; if (!contains(band, x, y)) return false; @@ -46,8 +70,14 @@ bool ReaSamplerEditor::mouseDownDeck(const FaceLayout& fl, int x, int y) { invalidate(); break; } + case ParamControl::kFilterLaw: + // Inert while the filter is off, matching its Disabled paint. + if (!params_.play.filter.enabled) break; + applyParamControl(hit.id, 0.0, hit.segment); + commitAndReload(); + break; default: - // Parameter-set toggles (play mode / pitch engine / pitch-env enable). + // Parameter-set toggles (play mode / pitch engine / pitch-env + filter enable). applyParamControl(hit.id, 0.0, hit.segment); commitAndReload(); break; @@ -55,12 +85,8 @@ bool ReaSamplerEditor::mouseDownDeck(const FaceLayout& fl, int x, int y) { return true; } if (hit.kind == DeckHitKind::Knob) { - // PITCH ENV knobs are Disabled (drawn, inert) while the envelope is off. - const bool pitchEnvKnob = - hit.id == static_cast(ParamControl::kPitchEnvAttack) || - hit.id == static_cast(ParamControl::kPitchEnvDecay) || - hit.id == static_cast(ParamControl::kPitchEnvDepth); - if (pitchEnvKnob && !params_.play.pitchEnv.enabled) return true; + // Knobs of a disabled group are drawn but inert. + if (deckKnobDisabled(hit.id)) return true; drag_ = DragKind::kDeckKnob; dragParamId_ = hit.id; dragKnobStartValue_ = deckControlNorm(hit.id); diff --git a/src/shell/instrument/editor_internal.h b/src/shell/instrument/editor_internal.h index a726eee..4669abb 100644 --- a/src/shell/instrument/editor_internal.h +++ b/src/shell/instrument/editor_internal.h @@ -1,8 +1,8 @@ // editor_internal.h — shared helpers for the ReaSamplerEditor TU family. Included ONLY by // the editor's own shell TUs (editor_session / editor_controls / editor_paint_* / // editor_input_* / editor_platform) — never a public seam. Holds the Rect<->kit adapters, -// small draw primitives (knob face / title band), label helpers, deck group ids, and the -// velocity-curve box derivation. All inline. +// small draw primitives (knob face / title band), label helpers, and the velocity-curve box +// derivation. All inline. #pragma once @@ -30,15 +30,6 @@ namespace reasampler::vst { -// Deck group ids (shell-owned; knob_deck treats them opaquely), left-to-right order. -enum DeckGroup { - kGroupAmpEnv = 0, - kGroupPitch, - kGroupPitchEnv, - kGroupVoice, - kGroupMaster, -}; - // Velocity-curve editor box metrics. The inset keeps node handles + the pick radius // inside the border so an endpoint at amp 0/1 stays grabbable; drag-off beyond // box+margin deletes the dragged node. diff --git a/src/shell/instrument/editor_paint_deck.cpp b/src/shell/instrument/editor_paint_deck.cpp index b9f90c8..6121e08 100644 --- a/src/shell/instrument/editor_paint_deck.cpp +++ b/src/shell/instrument/editor_paint_deck.cpp @@ -1,7 +1,7 @@ -// editor_paint_deck.cpp — the DECKS band's painter: the fenced control groups (AMP -// ENVELOPE / PITCH / PITCH ENV / VOICE / MASTER), their captions, the compact caption and -// row toggles, and the radial knobs with the label<->value swap on hover/drag. -// Windows-only; the deck's cell geometry is the pure knob_deck layout. +// editor_paint_deck.cpp — the DECKS band's painter: the fenced control groups, their +// captions, the compact caption and row toggles, and the radial knobs with the label<->value +// swap on hover/drag. Windows-only; the deck's cell geometry is the pure knob_deck layout and +// its group composition the pure deck_groups list. #include "shell/instrument/reasampler_editor.h" @@ -10,8 +10,9 @@ #include #include +#include "core/instrument/engine/filter/filter_morph.h" // MorphLaw (the law toggle's state) #include "core/instrument/ui/knob_deck.h" // deck layout + kDeckKnobSize -#include "shell/instrument/editor_internal.h" // kit adapters + knob face + DeckGroup ids +#include "shell/instrument/editor_internal.h" // kit adapters + knob face #include "shell/instrument/reasampler_processor.h" namespace reasampler::vst { @@ -68,6 +69,18 @@ void ReaSamplerEditor::paintDeck(LICE_IBitmap* bmp, const FaceLayout& fl) { case ParamControl::kPitchEnvDepth: return "P.Depth"; case ParamControl::kVoiceCount: return "Voices"; case ParamControl::kMasterGain: return "Gain"; + case ParamControl::kFilterMorph: return "Mode"; + case ParamControl::kFilterCutoff: return "Cutoff"; + case ParamControl::kFilterQ: return "Res"; + case ParamControl::kFilterDrive: return "Drive"; + case ParamControl::kFilterModAmt: return "Mod"; + case ParamControl::kFilterVel: return "Vel"; + case ParamControl::kFilterKeyTrack: return "Key Trk"; + case ParamControl::kFilterEnvAttack: return "F.Att"; + case ParamControl::kFilterEnvHold: return "F.Hold"; + case ParamControl::kFilterEnvDecay: return "F.Dec"; + case ParamControl::kFilterEnvSustain: return "F.Sus"; + case ParamControl::kFilterEnvRelease: return "F.Rel"; default: return ""; } }; @@ -79,11 +92,13 @@ void ReaSamplerEditor::paintDeck(LICE_IBitmap* bmp, const FaceLayout& fl) { hairline, 1.0f, 0); const char* caption = ""; switch (g.id) { - case kGroupAmpEnv: caption = "AMP ENVELOPE"; break; - case kGroupPitch: caption = "PITCH"; break; - case kGroupPitchEnv: caption = "PITCH ENV"; break; - case kGroupVoice: caption = "VOICE"; break; - case kGroupMaster: caption = "MASTER"; break; + case kGroupAmpEnv: caption = "AMP ENVELOPE"; break; + case kGroupPitch: caption = "PITCH"; break; + case kGroupPitchEnv: caption = "PITCH ENV"; break; + case kGroupFilter: caption = "FILTER"; break; + case kGroupFilterEnv: caption = "FILTER ENV"; break; + case kGroupVoice: caption = "VOICE"; break; + case kGroupMaster: caption = "MASTER"; break; default: break; } kitText(bmp, g.caption, caption, Font::Micro, Role::TextDim); @@ -105,20 +120,30 @@ void ReaSamplerEditor::paintDeck(LICE_IBitmap* bmp, const FaceLayout& fl) { case ParamControl::kVoiceMode: drawToggle(g.captionToggle, "Poly", "Mono", isMono, false); break; + case ParamControl::kFilterEnable: + drawToggle(g.captionToggle, "Off", "On", play.filter.enabled, false); + break; default: break; } } - // The row toggle (VOICE group's Retrig|Legato) — live only in Mono. + // Row toggles: VOICE's Retrig|Legato (live only in Mono) and FILTER's morph law. if (g.rowToggle.id >= 0) { - drawToggle(g.rowToggle, "Retrig", "Legato", - monoTrigger_ == MonoTrigger::Legato, !isMono); + if (static_cast(g.rowToggle.id) == ParamControl::kFilterLaw) { + drawToggle(g.rowToggle, "Band", "Notch", + play.filter.settings.morphLaw == + instrument::engine::filter::MorphLaw::HighNotchLow, + !play.filter.enabled); + } else { + drawToggle(g.rowToggle, "Retrig", "Legato", + monoTrigger_ == MonoTrigger::Legato, !isMono); + } } - // The knobs. PITCH ENV knobs draw Disabled (not hidden) while the envelope is off — - // stable geometry. + // The knobs. A dependent group's knobs draw Disabled (not hidden) — stable geometry. + // The predicate is the input side's, so the drawn state and the inert grab agree. for (const DeckCellLayout& c : g.cells) { if (c.id < 0) continue; // reserved blank cell (the Trigger face's two spares) - const bool disabled = (g.id == kGroupPitchEnv && !play.pitchEnv.enabled); + const bool disabled = deckKnobDisabled(c.id); const bool dragging = (drag_ == DragKind::kDeckKnob && dragParamId_ == c.id); const bool hov = !disabled && isHovered(HoverKind::kControl, c.id); const InteractionState st = diff --git a/src/shell/instrument/reasampler_editor.h b/src/shell/instrument/reasampler_editor.h index dee3247..74d5b39 100644 --- a/src/shell/instrument/reasampler_editor.h +++ b/src/shell/instrument/reasampler_editor.h @@ -14,6 +14,7 @@ #include "public.sdk/source/common/pluginview.h" +#include "core/instrument/ui/deck_groups.h" // DeckParam / DeckGroupId / sampleDeckGroups #include "core/instrument/ui/editor_geometry.h" // Rect (shared sub-rect type) #include "core/instrument/ui/envelope_edit.h" // EnvClampBounds / NodeHit (envelope node hit-test/edit) #include "core/instrument/ui/envelope_overlay.h" // AmpEnvelope / EnvNode (envelope overlay draw seam) @@ -82,31 +83,9 @@ private: // Controls on the setup surface. The int value is the opaque control id the pure // knob_deck hit-test returns; the shell maps it to the one parameter set or a - // processor-side per-instance setter. - enum class ParamControl { - kPlayMode = 0, // Gate | Trigger toggle - kPitchEngine, // Varispeed | Preserve toggle - kAttack, // AHDSR attack (Gate) / — - kHold, // AHDSR hold (Gate) - kDecay, // AHDSR decay (Gate) - kSustain, // AHDSR sustain (Gate) - kRelease, // AHDSR release (Gate) - kTrigLength, // Trigger %-length - kTrigFadeIn, // Trigger fade-in - kTrigFadeOut, // Trigger fade-out - kPitchEnvEnable, // AD pitch envelope on|off - kPitchEnvAttack, // AD pitch attack - kPitchEnvDecay, // AD pitch decay - kPitchEnvDepth, // AD pitch depth in +/- semitones - kKeyTrack, // key-tracking 0..200% (lives on InstrumentParams, not PlaySeconds) - // Deck-only controls: processor-side per-instance params — routed to the processor - // setters, never through applyParamControl. - kVoiceCount, // polyphony bound (1..32) — a stepped knob in the VOICE group - kVoiceMode, // Poly | Mono caption toggle (VOICE group) - kMonoTrigger, // Retrig | Legato row toggle (VOICE group; live only in Mono) - kMasterGain, // post-mixer master gain knob (-inf..+24 dB taper, MASTER group) - kCount - }; + // processor-side per-instance setter. The id space and the deck's group composition are + // the pure deck_groups module's — this alias keeps the shell's spelling. + using ParamControl = instrument::ui::DeckParam; // The waveform markers on the waveform band: start-point + the sustain loop's two ends, // in draw + hit order. @@ -188,6 +167,10 @@ private: bool mouseDownDeck(const FaceLayout& fl, int x, int y); void mouseDownBrowse(int w, int h, int x, int y); + // Whether deck knob `id` belongs to a group whose enable toggle is off. The ONE predicate + // behind both the Disabled paint and the inert grab, so they cannot disagree. + bool deckKnobDisabled(int id) const; + // Live drag resolution, split on the same axis; each handles only its own DragKind // values and is called from onMouseMove's router. void dragChrome(const FaceLayout& fl, int x, int y); // kRootMarker @@ -349,12 +332,6 @@ private: // Persisted preview velocity as a 0..1 slider value (MIDI 1..127 -> [0,1]). double previewVelocity01() const; - // The deck groups: AMP ENVELOPE (Gate A/H/D/S/R; Trigger Fade In/Length %/Fade Out + two - // reserved blanks so a mode flip never reflows neighbours) / PITCH (Key Track) / PITCH - // ENV (P.Attack/P.Decay/P.Depth) / VOICE (Voices knob + Poly|Mono + Retrig|Legato) / - // MASTER (Gain knob). - std::vector deckGroupDescs(const PlaySeconds& play) const; - // The normalized [0,1] value a deck knob shows — parameter-set ids route through // controlValue/keyTrack; processor-side ids (voice count, master gain, preview velocity // via the -2 sentinel) read the processor's live value. diff --git a/tests/test_component_state_io.cpp b/tests/test_component_state_io.cpp index 76d086d..94f212c 100644 --- a/tests/test_component_state_io.cpp +++ b/tests/test_component_state_io.cpp @@ -92,10 +92,10 @@ struct Zone { std::vector curve; // empty -> the flat endpoints }; -static void putZone(std::vector& out, const Zone& z, std::uint32_t pv) { - strv(out, z.sampleId); - u32v(out, static_cast(z.lowNote)); - u32v(out, static_cast(z.highNote)); +// Everything after a zone's id and key range — which is EXACTLY the whole v8 single record, +// so the two shapes are written from one place here just as the codec writes them from one +// place (putOverrides + the shared play tail). +static void putRecordBody(std::vector& out, const Zone& z, std::uint32_t pv) { u8v(out, z.rootOverride >= 0 ? 1 : 0); if (z.rootOverride >= 0) u32v(out, static_cast(z.rootOverride)); if (pv >= 2) { @@ -133,6 +133,13 @@ static void putZone(std::vector& out, const Zone& z, std::uint32_t } } +static void putZone(std::vector& out, const Zone& z, std::uint32_t pv) { + strv(out, z.sampleId); + u32v(out, static_cast(z.lowNote)); + u32v(out, static_cast(z.highNote)); + putRecordBody(out, z, pv); +} + // The envelope fields, in wire order. A builder at version N emits only the prefix fields // version N carried, so each lift can be asserted against a blob shaped exactly as that // version's writer produced. @@ -178,6 +185,33 @@ static std::vector envelopeWithZones(const Envelope& env, return out; } +// The CURRENT envelope carrying a payload-v8 SINGLE RECORD — the shape immediately before the +// filter tail. The shipping writer only emits v9, so a v8 blob can come from nowhere but +// bytes laid out here, which is what makes the off/neutral filter lift provable rather than +// assumed. +static std::vector envelopeWithV8Record(const std::string& selectionId, + const Zone& record) { + Envelope env; + env.selectionId = selectionId; + std::vector out; + u32v(out, env.version); + u8v(out, env.modeByte); + i64v(out, env.assignGeneration); + u8v(out, env.previewVelocity); + u8v(out, env.voiceCount); + u8v(out, env.voiceMode); + u8v(out, env.monoTrigger); + f64v(out, env.masterGain); + u8v(out, env.channelModeExplicit); + u32v(out, 0); // sample-refs: empty table + strv(out, env.instanceGuid); + strv(out, env.selectionId); + u32v(out, kParamsFormatMarker); + u32v(out, 8); + putRecordBody(out, record, 7); // the v7 zone tail IS the v8 single record's body + return out; +} + // Shorthand for the common case: the CURRENT envelope version carrying a zone payload. static std::vector envelopeWithZones(const std::string& selectionId, const std::vector& zones, @@ -388,7 +422,7 @@ static void testGoldenFullBlobFixture() { 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x01,0x00,0x00, 0x00,0x05,0x00,0x00,0x00,0x53,0x6e,0x61,0x72,0x65,0x13,0x00,0x00,0x00,0x67,0x75, 0x69,0x64,0x2d,0x31,0x32,0x33,0x34,0x2d,0x35,0x36,0x37,0x38,0x2d,0x61,0x62,0x63, - 0x64,0x04,0x00,0x00,0x00,0x6b,0x69,0x63,0x6b,0x00,0xff,0xff,0xff,0x08,0x00,0x00, + 0x64,0x04,0x00,0x00,0x00,0x6b,0x69,0x63,0x6b,0x00,0xff,0xff,0xff,0x09,0x00,0x00, 0x00,0x01,0x24,0x00,0x00,0x00,0x01,0x01,0xe8,0x03,0x00,0x00,0x00,0x00,0x00,0x00, 0x88,0x13,0x00,0x00,0x00,0x00,0x00,0x00,0x01,0xfa,0x00,0x00,0x00,0x00,0x00,0x00, 0x00,0x01,0x9a,0x99,0x99,0x99,0x99,0x99,0xa9,0x3f,0x00,0x00,0x00,0x00,0x00,0x00, @@ -401,13 +435,39 @@ static void testGoldenFullBlobFixture() { 0x9a,0x99,0x99,0x99,0x99,0x99,0xc9,0x3f,0x00,0x00,0x00,0x00,0x00,0x00,0x50,0x40, 0x33,0x33,0x33,0x33,0x33,0x33,0xe3,0x3f,0x00,0x00,0x00,0x00,0x00,0xc0,0x5f,0x40, 0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f, + // --- payload v9 filter tail, at its OFF/NEUTRAL default (this fixture sets no + // filter field), in the header's documented order --- + 0x00, // enabled = false + 0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f, // cutoffNorm 1.0 + 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // resonanceNorm 0.0 + 0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f, // morphNorm 1.0 + 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // driveNorm 0.0 + 0x00, // morphLaw = HighBandLow + 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // modAmount 0.0 + 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // velAmount 0.0 + 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // keyTrack 0.0 + 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // env attack 0.0 + 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // env hold 0.0 + 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // env decay 0.0 + 0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f, // env sustain 1.0 + 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // env release 0.0 + 0x02,0x00,0x00,0x00, // filter curve: 2 points (linear) + 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // velocity 0.0 + 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // amp 0.0 + 0x00,0x00,0x00,0x00,0x00,0xc0,0x5f,0x40, // velocity 127.0 + 0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f, // amp 1.0 }; // clang-format on CHECK(bytes.size() == sizeof(kGolden)); if (bytes.size() == sizeof(kGolden)) { bool same = true; for (std::size_t i = 0; i < bytes.size(); ++i) { - if (bytes[i] != kGolden[i]) { same = false; break; } + if (bytes[i] != kGolden[i]) { + std::printf(" golden byte %zu: got 0x%02x, want 0x%02x\n", i, + bytes[i], kGolden[i]); + same = false; + break; + } } CHECK(same); } @@ -443,8 +503,109 @@ static void testEnvelopePrefixBytesFrozen() { CHECK(bytes[4] == 0); // ChannelMode::Mono } CHECK(kComponentStateVersion == 11); - CHECK(kParamsPayloadVersion == 8); + CHECK(kParamsPayloadVersion == 9); + CHECK(kParamsSingleRecordVersion == 8); CHECK(kParamsFormatMarker == 0xFFFFFF00u); + // The filter tail rode a PAYLOAD bump, not an envelope one — the two axes stay + // independent, so a future envelope field cannot collide with it on one number. + CHECK(kParamsFilterVersion > kParamsSingleRecordVersion); +} + +// --- The filter tail (payload v9) -------------------------------------------- + +// A v8 blob is a strict prefix of v9, so it must lift to the OFF/NEUTRAL filter — the reason +// a project saved before the filter existed reopens sounding identical. Everything the v8 +// record did carry must survive alongside it. +static void testV8RecordLiftsToTheOffNeutralFilter() { + legacy::Zone rec; + rec.rootOverride = 48; + rec.startPoint = 512; + rec.holdSeconds = 0.25; + rec.attackSeconds = 0.011; + rec.releaseSeconds = 0.222; + rec.keyTrack = 0.75; + rec.preserve = true; + const ComponentState out = + deserializeComponentState(envelopeWithV8Record("kick", rec), 48000.0); + + CHECK(out.selectionId == "kick"); + CHECK(out.params.rootOverride && *out.params.rootOverride == 48); + CHECK(out.params.startPoint && *out.params.startPoint == 512); + CHECK(out.params.keyTrack == 0.75); + CHECK(out.params.play.adsr.holdSeconds == 0.25); + CHECK(out.params.play.adsr.releaseSeconds == 0.222); + CHECK(out.params.play.pitchEngine == PitchEngine::Preserve); + + // The lift, field by field: nothing engaged, nothing modulating, a flat unity envelope. + const FilterSeconds& f = out.params.play.filter; + const FilterSeconds def; + CHECK(!f.enabled); + CHECK(f.settings.cutoffNorm == def.settings.cutoffNorm); + CHECK(f.settings.resonanceNorm == def.settings.resonanceNorm); + CHECK(f.settings.morphNorm == def.settings.morphNorm); + CHECK(f.settings.driveNorm == def.settings.driveNorm); + CHECK(f.settings.morphLaw == reasampler::instrument::engine::filter::MorphLaw::HighBandLow); + CHECK(f.modAmount == 0.0); + CHECK(f.velAmount == 0.0); + CHECK(f.keyTrack == 0.0); + CHECK(f.env.sustainLevel == 1.0); + CHECK(f.env.attackSeconds == 0.0 && f.env.decaySeconds == 0.0 && + f.env.releaseSeconds == 0.0 && f.env.holdSeconds == 0.0); + + // Re-saving lifts it into the current format, and that blob is what the writer would have + // produced for the same state — so the lift is stable, not one-way lossy. + ComponentState resaved = out; + CHECK(serializeComponentState(resaved) == + serializeComponentState(deserializeComponentState( + serializeComponentState(resaved), 48000.0))); +} + +// The v9 tail round-trips losslessly, including the morph law's non-default leg and a filter +// velocity curve distinct from the amp's. +static void testFilterTailRoundTripsLosslessly() { + ComponentState in; + in.selectionId = "pad"; + FilterSeconds& f = in.params.play.filter; + f.enabled = true; + f.settings.cutoffNorm = 0.375f; + f.settings.resonanceNorm = 0.8125f; + f.settings.morphNorm = 0.25f; + f.settings.driveNorm = 0.5f; + f.settings.morphLaw = reasampler::instrument::engine::filter::MorphLaw::HighNotchLow; + f.modAmount = -0.625; + f.velAmount = 0.5; + f.keyTrack = 1.5; + f.env.attackSeconds = 0.031; + f.env.holdSeconds = 0.062; + f.env.decaySeconds = 0.125; + f.env.sustainLevel = 0.25; + f.env.releaseSeconds = 0.5; + f.velocityCurve = reasampler::instrument::engine::VelocityCurve::fromPoints( + {VelocityPoint{0.0, 0.1}, VelocityPoint{100.0, 0.4}, VelocityPoint{127.0, 0.9}}); + // The amp's own curve stays different, so a codec that read one into the other fails here. + in.params.velocityCurve = reasampler::instrument::engine::VelocityCurve::flat(); + + const ComponentState out = + deserializeComponentState(serializeComponentState(in), 48000.0); + const FilterSeconds& g = out.params.play.filter; + CHECK(g.enabled); + CHECK(g.settings.cutoffNorm == f.settings.cutoffNorm); + CHECK(g.settings.resonanceNorm == f.settings.resonanceNorm); + CHECK(g.settings.morphNorm == f.settings.morphNorm); + CHECK(g.settings.driveNorm == f.settings.driveNorm); + CHECK(g.settings.morphLaw == reasampler::instrument::engine::filter::MorphLaw::HighNotchLow); + CHECK(g.modAmount == f.modAmount); + CHECK(g.velAmount == f.velAmount); + CHECK(g.keyTrack == f.keyTrack); + CHECK(g.env.attackSeconds == f.env.attackSeconds); + CHECK(g.env.holdSeconds == f.env.holdSeconds); + CHECK(g.env.decaySeconds == f.env.decaySeconds); + CHECK(g.env.sustainLevel == f.env.sustainLevel); + CHECK(g.env.releaseSeconds == f.env.releaseSeconds); + CHECK(g.velocityCurve.size() == 3); + CHECK(g.velocityCurve.equals(f.velocityCurve)); + CHECK(out.params.velocityCurve.equals( + reasampler::instrument::engine::VelocityCurve::flat())); } // The WRITER emits the CURRENT payload version, and the marker + version sit at the head of @@ -643,7 +804,7 @@ static void testEveryOlderPayloadVersionMigrates() { (pv >= 5 ? 0.4 : AdsrSeconds{}.releaseSeconds)); } // And the CURRENT version does NOT take the migration path: it reads its own record. - CHECK(kParamsPayloadVersion == 8); + CHECK(kParamsPayloadVersion >= kParamsSingleRecordVersion); } // The LEGACY v3 payload's wall-clock frame counts convert to seconds at the READ boundary @@ -814,11 +975,12 @@ static void testSampleRefsTruncatedMidEntry() { std::vector bytes = serializeComponentState(s); // The tail after the refs table is instanceGuid(4, empty) + selectionId(4+4="kick") + // the current params payload for DEFAULT params (marker4+version4 + overrides3 + the - // 91-byte play tail + keyTrack8 + curve(4+2*16, the flat 2-point default)) = 158 bytes; - // entry two is 47 bytes (id 4+3, path 4+7, root4, loop 1+8+8, channels4, name 4+0). - // Cutting 178 bytes keeps the first 27 of entry two's 47 — mid loop.start (offset 23..31). - CHECK(bytes.size() > 178); - bytes.resize(bytes.size() - 178); + // 91-byte play tail + keyTrack8 + curve(4+2*16, the flat 2-point default) + the 134-byte + // v9 filter tail) = 292 bytes; entry two is 47 bytes (id 4+3, path 4+7, root4, loop + // 1+8+8, channels4, name 4+0). Cutting 312 keeps the first 27 of entry two's 47 — mid + // loop.start (offset 23..31). + CHECK(bytes.size() > 312); + bytes.resize(bytes.size() - 312); const ComponentState back = deserializeComponentState(bytes, 44100.0); CHECK(back.sampleRefs.size() == 1); CHECK(back.sampleRefs.size() == 1 && back.sampleRefs[0].sampleId == "kick"); @@ -916,6 +1078,8 @@ int main() { testUnknownEnvelopeVersionIsEmpty(); testV1SelectionLift(); testTruncationDegradesCleanly(); + testV8RecordLiftsToTheOffNeutralFilter(); + testFilterTailRoundTripsLosslessly(); if (failures == 0) { std::printf("component_state_io_tests: all tests passed\n"); return 0; diff --git a/tests/test_deck_groups.cpp b/tests/test_deck_groups.cpp new file mode 100644 index 0000000..7174ead --- /dev/null +++ b/tests/test_deck_groups.cpp @@ -0,0 +1,182 @@ +// 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. + +#include "../src/core/instrument/ui/deck_groups.h" + +#include +#include +#include + +using namespace reasampler; +using namespace reasampler::instrument::ui; + +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; + +static int indexOfGroup(const std::vector& g, int id) { + for (std::size_t i = 0; i < g.size(); ++i) { + if (g[i].id == id) return static_cast(i); + } + return -1; +} + +static int cell(DeckParam p) { return static_cast(p); } + +static void testDeckReadsPitchThenFilterThenAmpLeftToRight() { + for (PlayMode mode : {PlayMode::Gate, PlayMode::Trigger}) { + const std::vector g = sampleDeckGroups(mode); + const int pitch = indexOfGroup(g, kGroupPitch); + const int penv = indexOfGroup(g, kGroupPitchEnv); + const int filt = indexOfGroup(g, kGroupFilter); + const int fenv = indexOfGroup(g, kGroupFilterEnv); + const int amp = indexOfGroup(g, kGroupAmpEnv); + CHECK(pitch >= 0 && penv >= 0 && filt >= 0 && fenv >= 0 && amp >= 0); + // The signal flow, left to right. Each envelope group trails its own stage. + CHECK(pitch < penv); + CHECK(penv < filt); + CHECK(filt < fenv); + CHECK(fenv < amp); + // The two instance-wide groups stay at the end. + CHECK(amp < indexOfGroup(g, kGroupVoice)); + CHECK(indexOfGroup(g, kGroupVoice) < indexOfGroup(g, kGroupMaster)); + } +} + +static void testFilterGroupCarriesItsFiveToneControlsPlusModulation() { + const std::vector& g = sampleDeckGroups(PlayMode::Gate); + const DeckGroupDesc& f = g[static_cast(indexOfGroup(g, kGroupFilter))]; + const std::vector expected = { + cell(DeckParam::kFilterMorph), cell(DeckParam::kFilterCutoff), + cell(DeckParam::kFilterQ), cell(DeckParam::kFilterDrive), + cell(DeckParam::kFilterModAmt), cell(DeckParam::kFilterVel), + cell(DeckParam::kFilterKeyTrack)}; + CHECK(f.cellIds == expected); + // Off by default is a state question, but reachability is a layout one: the enable + // toggle is in the caption row and the morph law in the knob row. + CHECK(f.captionToggle.id == cell(DeckParam::kFilterEnable)); + CHECK(f.rowToggle.id == cell(DeckParam::kFilterLaw)); + + const DeckGroupDesc& fe = g[static_cast(indexOfGroup(g, kGroupFilterEnv))]; + const std::vector env = { + cell(DeckParam::kFilterEnvAttack), cell(DeckParam::kFilterEnvHold), + cell(DeckParam::kFilterEnvDecay), cell(DeckParam::kFilterEnvSustain), + cell(DeckParam::kFilterEnvRelease)}; + CHECK(fe.cellIds == env); + // The filter envelope has no enable of its own — the FILTER group's toggle governs both. + CHECK(fe.captionToggle.id == -1); + CHECK(fe.rowToggle.id == -1); +} + +static void testAmpGroupWidthSurvivesAGateTriggerFlip() { + // The reserved blanks are what stop a mode flip reflowing the groups beside AMP. + const std::vector gate = sampleDeckGroups(PlayMode::Gate); + const std::vector trig = sampleDeckGroups(PlayMode::Trigger); + const DeckGroupDesc& a = gate[static_cast(indexOfGroup(gate, kGroupAmpEnv))]; + const DeckGroupDesc& b = trig[static_cast(indexOfGroup(trig, kGroupAmpEnv))]; + CHECK(deckGroupWidth(a) == deckGroupWidth(b)); + 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() { + 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 is wider than the row on its own. + CHECK(deckRowCount(g, kAvailAtMinWidth) == 4); + CHECK(deckHeight(g, kAvailAtMinWidth) == 4 * kDeckGroupH + 3 * 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); + CHECK(dl.groups.size() == g.size()); + for (const DeckGroupLayout& gl : dl.groups) { + CHECK(gl.box.x >= 8); + CHECK(gl.box.height == kDeckGroupH); + } +} + +static void testHitTestResolvesTheNewFilterControls() { + const std::vector g = sampleDeckGroups(PlayMode::Gate); + const DeckLayout dl = layoutDeck(g, 8, 40, kAvailAtDefaultWidth); + const DeckGroupLayout& f = + dl.groups[static_cast(indexOfGroup(g, kGroupFilter))]; + + // Every knob cell resolves to its own id, from the centre of its cell. + for (const DeckCellLayout& c : f.cells) { + const DeckHit hit = hitTestDeck(dl, c.cell.x + c.cell.width / 2, + c.cell.y + c.cell.height / 2); + CHECK(hit.kind == DeckHitKind::Knob); + CHECK(hit.id == c.id); + } + CHECK(f.cells.size() == 7); + CHECK(f.cells[1].id == cell(DeckParam::kFilterCutoff)); + + // The enable toggle's two segments and the morph-law row toggle's two. + const DeckHit off = hitTestDeck(dl, f.captionToggle.seg0.x + 2, + f.captionToggle.seg0.y + 2); + CHECK(off.kind == DeckHitKind::CaptionToggle); + CHECK(off.id == cell(DeckParam::kFilterEnable) && off.segment == 0); + const DeckHit on = hitTestDeck(dl, f.captionToggle.seg1.x + 2, + f.captionToggle.seg1.y + 2); + CHECK(on.id == cell(DeckParam::kFilterEnable) && on.segment == 1); + + const DeckHit band = hitTestDeck(dl, f.rowToggle.seg0.x + 2, f.rowToggle.seg0.y + 2); + CHECK(band.kind == DeckHitKind::RowToggle); + CHECK(band.id == cell(DeckParam::kFilterLaw) && band.segment == 0); + const DeckHit notch = hitTestDeck(dl, f.rowToggle.seg1.x + 2, f.rowToggle.seg1.y + 2); + CHECK(notch.id == cell(DeckParam::kFilterLaw) && notch.segment == 1); + + // The filter-envelope knobs resolve too, and are distinct ids from the amp's. + const DeckGroupLayout& fe = + dl.groups[static_cast(indexOfGroup(g, kGroupFilterEnv))]; + const DeckHit attack = hitTestDeck(dl, fe.cells[0].cell.x + 4, fe.cells[0].cell.y + 4); + CHECK(attack.kind == DeckHitKind::Knob); + CHECK(attack.id == cell(DeckParam::kFilterEnvAttack)); + CHECK(attack.id != cell(DeckParam::kAttack)); +} + +static void testBipolarKnobLawRoundTripsAndIsExactAtCentre() { + // Centre is EXACT in both directions: a knob parked at 0.5 stores 0, and 0 reads back + // 0.5 — no residual modulation from a rounding hair. + CHECK(deckBipolarFromNorm(0.5) == 0.0); + CHECK(deckNormFromBipolar(0.0) == 0.5); + CHECK(deckBipolarFromNorm(0.0) == -1.0); + CHECK(deckBipolarFromNorm(1.0) == 1.0); + for (int i = 0; i <= 200; ++i) { + const double norm = static_cast(i) / 200.0; + CHECK(std::fabs(deckNormFromBipolar(deckBipolarFromNorm(norm)) - norm) < 1e-12); + const double value = -1.0 + static_cast(i) / 100.0; + CHECK(std::fabs(deckBipolarFromNorm(deckNormFromBipolar(value)) - value) < 1e-12); + } + // Out of range clamps rather than extrapolating. + CHECK(deckBipolarFromNorm(-3.0) == -1.0); + CHECK(deckBipolarFromNorm(3.0) == 1.0); + CHECK(deckNormFromBipolar(-3.0) == 0.0); + CHECK(deckNormFromBipolar(3.0) == 1.0); +} + +int main() { + testDeckReadsPitchThenFilterThenAmpLeftToRight(); + testFilterGroupCarriesItsFiveToneControlsPlusModulation(); + testAmpGroupWidthSurvivesAGateTriggerFlip(); + testWrappedDeckHeightAtThePinnedEditorWidths(); + testHitTestResolvesTheNewFilterControls(); + testBipolarKnobLawRoundTripsAndIsExactAtCentre(); + if (g_fail == 0) std::printf("deck_groups: all tests passed\n"); + return g_fail == 0 ? 0 : 1; +} diff --git a/tests/test_sample_map.cpp b/tests/test_sample_map.cpp index 10761ef..00a2836 100644 --- a/tests/test_sample_map.cpp +++ b/tests/test_sample_map.cpp @@ -610,6 +610,57 @@ static void testResolvePlayConvertsWallClockAtTheRate() { CHECK(at96.trigger.fadeInFrames == 441); // still unconverted } +static void testResolvePlayCarriesTheFilterAndResolvesOnlyItsEnvelope() { + // The filter's control positions are already rate-free, so only its envelope crosses the + // seconds->frames boundary. A converted norm would be a bug in the other direction: the + // same preset must sound identical at 48k and 96k. + PlaySeconds st; + st.filter.enabled = true; + st.filter.settings.cutoffNorm = 0.25f; + st.filter.settings.resonanceNorm = 0.75f; + st.filter.settings.morphNorm = 0.5f; + st.filter.settings.driveNorm = 0.125f; + st.filter.settings.morphLaw = reasampler::instrument::engine::filter::MorphLaw::HighNotchLow; + st.filter.modAmount = -0.5; + st.filter.velAmount = 0.25; + st.filter.keyTrack = 1.25; + st.filter.env.attackSeconds = 0.01; + st.filter.env.holdSeconds = 0.02; + st.filter.env.decaySeconds = 0.03; + st.filter.env.sustainLevel = 0.4; + st.filter.env.releaseSeconds = 0.05; + + const PlayParams at48 = resolvePlay(st, 48000); + CHECK(at48.filter.enabled); + CHECK(at48.filter.settings.cutoffNorm == 0.25f); + CHECK(at48.filter.settings.resonanceNorm == 0.75f); + CHECK(at48.filter.settings.morphNorm == 0.5f); + CHECK(at48.filter.settings.driveNorm == 0.125f); + CHECK(at48.filter.settings.morphLaw == reasampler::instrument::engine::filter::MorphLaw::HighNotchLow); + CHECK(at48.filter.modAmount == -0.5); + CHECK(at48.filter.velAmount == 0.25); + CHECK(at48.filter.keyTrack == 1.25); + CHECK(at48.filter.env.attackFrames == 480); + CHECK(at48.filter.env.holdFrames == 960); + CHECK(at48.filter.env.decayFrames == 1440); + CHECK(at48.filter.env.sustainLevel == 0.4); // a level, not a time + CHECK(at48.filter.env.releaseFrames == 2400); + + const PlayParams at96 = resolvePlay(st, 96000); + CHECK(at96.filter.env.attackFrames == 960); + CHECK(at96.filter.env.releaseFrames == 4800); + CHECK(at96.filter.settings.cutoffNorm == 0.25f); // rate-free: unchanged + + // Off by default, and the default envelope is a flat unity so a disengaged filter has + // nothing to modulate with either. + const PlayParams bare = resolvePlay(PlaySeconds{}, 48000); + CHECK(!bare.filter.enabled); + CHECK(bare.filter.modAmount == 0.0); + CHECK(bare.filter.velAmount == 0.0); + CHECK(bare.filter.keyTrack == 0.0); + CHECK(bare.filter.env.sustainLevel == 1.0); +} + static void testResolvePlayRoundsAndFloorsNegatives() { PlaySeconds st; st.adsr.attackSeconds = 0.0001; // 4.41 frames at 44.1k -> rounds to 4 @@ -845,6 +896,7 @@ int main() { testRetainRefsFiltersToPlayedSet(); testLegacyLiftDecision(); testResolvePlayConvertsWallClockAtTheRate(); + testResolvePlayCarriesTheFilterAndResolvesOnlyItsEnvelope(); testResolvePlayRoundsAndFloorsNegatives(); testResolveCaptureUsesIntrinsicsWhenNoOverride(); testResolveCaptureOverridesBeatIntrinsics(); diff --git a/tests/test_sampler_filter.cpp b/tests/test_sampler_filter.cpp new file mode 100644 index 0000000..eb4fa7b --- /dev/null +++ b/tests/test_sampler_filter.cpp @@ -0,0 +1,294 @@ +// Standalone tests for the filter's place in the VOICE PATH — no VST3, no REAPER, no +// 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. + +#include "../src/core/instrument/engine/voice.h" + +#include +#include +#include +#include + +using namespace reasampler; +namespace flt = reasampler::instrument::engine::filter; + +static int g_fail = 0; +#define CHECK(cond) do { if(!(cond)) { \ + std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) + +constexpr double kPi = 3.14159265358979323846; +constexpr int kRate = 48000; + +// The port's whole point, asserted from the CALL SITE as well as inside the module: neither +// the voice nor the envelope it drives may grow a vtable, and the filter stays a plain value +// member (trivially copyable => nothing heap-owned, so process() cannot allocate). +static_assert(!std::is_polymorphic_v, "no vtable on the per-sample path"); +static_assert(!std::is_polymorphic_v, "the filter envelope must inline"); +static_assert(std::is_trivially_copyable_v, "filter state is plain values"); + +// A sine whose period is EXACTLY `period` frames, so two voices started `period` apart read +// identical values at every absolute frame — that is what isolates envelope phase from read +// position in the per-voice-independence test. +static SampleData periodicSine(std::size_t frames, double period, int rootNote = 60) { + SampleData s; + s.frames.resize(frames); + for (std::size_t i = 0; i < frames; ++i) { + s.frames[i] = static_cast(std::sin(2.0 * kPi * static_cast(i) / period)); + } + s.sampleRate = kRate; + s.rootNote = rootNote; + return s; +} + +// Amp envelope held wide open, so a rendered frame is exactly the (filtered) source. +static AdsrParams flatAdsr() { + AdsrParams a; + a.sustainLevel = 1.0; + return a; +} + +static FilterParams engagedFilter(float cutoffNorm, float resNorm, float morphNorm) { + FilterParams f; + f.enabled = true; + f.settings.cutoffNorm = cutoffNorm; + f.settings.resonanceNorm = resNorm; + f.settings.morphNorm = morphNorm; + f.env.sustainLevel = 1.0; + return f; +} + +static std::vector render(SampleData& sample, int note, int velocity, int frames) { + Voice v; + v.start(note, velocity, sample); + std::vector out(static_cast(frames), 0.0); + for (int i = 0; i < frames; ++i) out[static_cast(i)] = v.renderFrame(); + return out; +} + +static double rms(const std::vector& x, std::size_t from, std::size_t to) { + double acc = 0.0; + for (std::size_t i = from; i < to; ++i) acc += x[i] * x[i]; + return std::sqrt(acc / static_cast(to - from)); +} + +// --------------------------------------------------------------------------- +// Off by default. +// --------------------------------------------------------------------------- + +static void testDisengagedFilterIsBitInertEvenWithExtremeSettingsStored() { + SampleData plain = periodicSine(2000, 64); + plain.play.adsr = flatAdsr(); + + // Independent reference: with a flat amp envelope, unity velocity curve and the note at + // the root, the rendered frame IS the source frame. Asserting against this rather than + // against another render of the same code proves the un-filtered path is still correct, + // not merely self-consistent. + const std::vector bare = render(plain, 60, 100, 1500); + for (std::size_t i = 0; i < bare.size(); ++i) { + CHECK(static_cast(bare[i]) == plain.frames[i]); + } + + // Now store the most violent filter settings available and leave `enabled` false. A blob + // that carries filter parameters must sound exactly as it did before they existed. + SampleData stored = periodicSine(2000, 64); + stored.play.adsr = flatAdsr(); + stored.play.filter = engagedFilter(0.0f, 1.0f, 1.0f); + stored.play.filter.enabled = false; + stored.play.filter.settings.driveNorm = 1.0f; + stored.play.filter.modAmount = 1.0; + stored.play.filter.velAmount = -1.0; + stored.play.filter.keyTrack = 2.0; + const std::vector inert = render(stored, 60, 100, 1500); + for (std::size_t i = 0; i < inert.size(); ++i) CHECK(inert[i] == bare[i]); + + // And engaging it must actually do something — otherwise the bit-identity above would be + // satisfied by a filter that never runs. + SampleData live = periodicSine(2000, 64); + live.play.adsr = flatAdsr(); + live.play.filter = engagedFilter(0.0f, 0.0f, 1.0f); // low-pass, corner at 20 Hz + const std::vector filtered = render(live, 60, 100, 1500); + CHECK(rms(filtered, 500, 1500) < 0.05 * rms(bare, 500, 1500)); +} + +// --------------------------------------------------------------------------- +// Pipeline position: pitch -> filter -> amp. +// --------------------------------------------------------------------------- + +static void testFilterSeesThePreAmpSignalSoAmpGainScalesTheResultLinearly() { + // With a NONLINEAR drive stage engaged, the two orderings are distinguishable: a filter + // fed the post-amp signal would see a half-level input and shape it differently, so the + // two renders could not be an exact factor of two apart. Filter-before-amp makes them + // exactly that, because the amp multiply is the last thing to happen. + const auto renderAtSustain = [](double sustain) { + SampleData s = periodicSine(2000, 64); + s.play.adsr = flatAdsr(); + s.play.adsr.sustainLevel = sustain; + s.play.filter = engagedFilter(0.35f, 0.9f, 1.0f); + s.play.filter.settings.driveNorm = 1.0f; // hard nonlinearity in the resonance path + return render(s, 60, 100, 1200); + }; + const std::vector half = renderAtSustain(0.5); + const std::vector full = renderAtSustain(1.0); + + bool sawSignal = false; + // Frame 0 is the AHDSR's attack peak (1.0) in BOTH runs; the sustain level takes over + // from frame 1. + for (std::size_t i = 1; i < half.size(); ++i) { + CHECK(half[i] == 0.5 * full[i]); + if (std::fabs(full[i]) > 1e-6) sawSignal = true; + } + CHECK(sawSignal); +} + +// --------------------------------------------------------------------------- +// Per-voice, not instance-wide. +// --------------------------------------------------------------------------- + +static void testTwoVoicesAtDifferentEnvelopePhasesFilterIndependently() { + // Both voices read the SAME source value at every absolute frame (voice B starts exactly + // one sine period late), and both amp envelopes are wide open — so the only thing that + // can separate their outputs is their own filter-envelope phase. A single shared filter + // would hand both voices one cutoff and one integrator pair, and they would match. + constexpr int kPeriod = 64; + SampleData s = periodicSine(20000, kPeriod); + s.play.adsr = flatAdsr(); + s.play.filter = engagedFilter(0.0f, 0.2f, 1.0f); + s.play.filter.modAmount = 1.0; // envelope sweeps cutoff over the full range + s.play.filter.env.attackFrames = 8000; // slow, so the two phases stay far apart + s.play.filter.env.sustainLevel = 1.0; + + Voice a; + Voice b; + a.start(60, 100, s); + for (int i = 0; i < kPeriod; ++i) a.renderFrame(); + b.start(60, 100, s); + + int differing = 0; + for (int i = 0; i < 4000; ++i) { + const double fa = a.renderFrame(); + const double fb = b.renderFrame(); + if (std::fabs(fa - fb) > 1e-6) ++differing; + } + CHECK(differing > 3000); + + // The same staggered pair with the MODULATION removed converges: each voice still owns + // its own integrators, so the stagger costs a short start-up transient, but with no + // envelope reaching cutoff the two settle onto the identical steady state. That is what + // makes the count above evidence of per-voice ENVELOPE phase rather than of the stagger. + SampleData flat = periodicSine(20000, kPeriod); + flat.play.adsr = flatAdsr(); + flat.play.filter = engagedFilter(0.5f, 0.2f, 1.0f); + Voice c; + Voice d; + c.start(60, 100, flat); + for (int i = 0; i < kPeriod; ++i) c.renderFrame(); + d.start(60, 100, flat); + int settledDiffering = 0; + for (int i = 0; i < 4000; ++i) { + const double fc = c.renderFrame(); + const double fd = d.renderFrame(); + if (i >= 2000 && fc != fd) ++settledDiffering; + } + CHECK(settledDiffering == 0); +} + +// --------------------------------------------------------------------------- +// Modulation: envelope depth, velocity, key tracking. +// --------------------------------------------------------------------------- + +static void testModAmountPolarityDrivesCutoffFromOppositeEnds() { + // A low-pass over a 750 Hz tone: energy tracks cutoff, so the sweep direction is readable + // straight off the output's RMS. +100% from a closed filter opens it; -100% from an open + // one closes it; 0% leaves it where the knob is. + const auto sweep = [](float cutoffNorm, double modAmount) { + SampleData s = periodicSine(20000, 64); + s.play.adsr = flatAdsr(); + s.play.filter = engagedFilter(cutoffNorm, 0.0f, 1.0f); + s.play.filter.modAmount = modAmount; + s.play.filter.env.attackFrames = 12000; + s.play.filter.env.sustainLevel = 1.0; + return render(s, 60, 100, 12000); + }; + + const std::vector rising = sweep(0.0f, 1.0); + CHECK(rms(rising, 10000, 12000) > 20.0 * rms(rising, 0, 2000)); + + const std::vector falling = sweep(1.0f, -1.0); + CHECK(rms(falling, 10000, 12000) < 0.05 * rms(falling, 0, 2000)); + + // Zero depth: the envelope is still running, but it must not reach cutoff at all. + const std::vector steady = sweep(0.5f, 0.0); + const double early = rms(steady, 2000, 4000); + const double late = rms(steady, 10000, 12000); + CHECK(std::fabs(late - early) < 1e-6 * early + 1e-9); + + // The two poles land at opposite ends of the same range: at full sweep the rising run + // ends open and the falling run ends closed. + CHECK(rms(rising, 10000, 12000) > rms(falling, 10000, 12000)); +} + +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. + const auto tone = [](double velAmount, double keyTrack) { + SampleData s = periodicSine(8000, 64); + s.play.adsr = flatAdsr(); + s.keyTrack = 0.0; + s.play.filter = engagedFilter(0.25f, 0.0f, 1.0f); + s.play.filter.velAmount = velAmount; + s.play.filter.keyTrack = keyTrack; + return s; + }; + + // Velocity: the default linear curve rises with velocity, so a positive depth opens the + // filter for a hard hit. The amp's own velocity curve is flat, so amplitude is unaffected. + SampleData vel = tone(1.0, 0.0); + const std::vector soft = render(vel, 60, 1, 6000); + const std::vector hard = render(vel, 60, 127, 6000); + CHECK(rms(hard, 2000, 6000) > 2.0 * rms(soft, 2000, 6000)); + + // Key tracking: two octaves up opens it by two octaves of cutoff. + SampleData key = tone(0.0, 1.0); + const std::vector low = render(key, 60, 100, 6000); + const std::vector high = render(key, 84, 100, 6000); + CHECK(rms(high, 2000, 6000) > 2.0 * rms(low, 2000, 6000)); + + // Both neutral: neither velocity nor note may move the filter. + SampleData neutral = tone(0.0, 0.0); + const std::vector a = render(neutral, 60, 1, 6000); + const std::vector b = render(neutral, 60, 127, 6000); + const std::vector c = render(neutral, 84, 100, 6000); + for (std::size_t i = 0; i < a.size(); ++i) { + CHECK(a[i] == b[i]); + CHECK(a[i] == c[i]); + } +} + +static void testNoteOnResetsTheFilterSoAPreviousNoteCannotLeak() { + SampleData s = periodicSine(8000, 64); + s.play.adsr = flatAdsr(); + s.play.filter = engagedFilter(0.2f, 0.95f, 1.0f); // high Q: a long, obvious ring + + Voice v; + v.start(60, 100, s); + for (int i = 0; i < 4000; ++i) v.renderFrame(); // fill the integrators + v.start(60, 100, s); // restart: reset() must clear them + std::vector restarted(1000); + for (int i = 0; i < 1000; ++i) restarted[static_cast(i)] = v.renderFrame(); + + const std::vector fresh = render(s, 60, 100, 1000); + for (std::size_t i = 0; i < fresh.size(); ++i) CHECK(restarted[i] == fresh[i]); +} + +int main() { + testDisengagedFilterIsBitInertEvenWithExtremeSettingsStored(); + testFilterSeesThePreAmpSignalSoAmpGainScalesTheResultLinearly(); + testTwoVoicesAtDifferentEnvelopePhasesFilterIndependently(); + testModAmountPolarityDrivesCutoffFromOppositeEnds(); + testVelocityAndKeyTrackingReachCutoffAndAreNoOpsAtTheirDefaults(); + testNoteOnResetsTheFilterSoAPreviousNoteCannotLeak(); + if (g_fail == 0) std::printf("sampler_filter: all tests passed\n"); + return g_fail == 0 ? 0 : 1; +}