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.
This commit is contained in:
2026-07-30 15:26:14 -04:00
parent c9c708a338
commit 67215509cb
25 changed files with 1354 additions and 191 deletions
+5 -4
View File
@@ -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 (132, 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 FritschCarlson 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.
+7 -2
View File
@@ -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)
+8 -6
View File
@@ -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.
+26 -2
View File
@@ -9,6 +9,7 @@
#include <vector>
#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"
+22
View File
@@ -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<double>(p.filter.settings.cutoffNorm);
filterModAmount_ = p.filter.modAmount;
filterKeyTrack_ = p.filter.keyTrack;
filterVelOffset_ =
p.filter.velAmount * p.filter.velocityCurve.eval(static_cast<double>(velocity));
filterRate_ = static_cast<double>(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
+92 -6
View File
@@ -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<double>(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<double>(filterBaseCutoff_) +
filterModAmount_ * filterEnv_.tick();
if (cut < 0.0) cut = 0.0;
if (cut > 1.0) cut = 1.0;
const int step = static_cast<int>(cut * kFilterModSteps + 0.5);
if (step == filterModStep_) return;
filterModStep_ = step;
filterSettings_.cutoffNorm = static_cast<float>(cut);
filter_.prepare(filterSettings_, filterRate_);
}
// 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<double>(note - sample_->rootNote) / 12.0) *
filterNormPerOctave();
}
if (base < 0.0) base = 0.0;
if (base > 1.0) base = 1.0;
filterBaseCutoff_ = static_cast<float>(base);
filterModStep_ = -1; // forces the next frame to solve
}
// 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<double>(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<std::size_t>(feedPos_)] : 0.0f;
shiftR_.setShiftRatio(shift);
outRlocal =
static_cast<double>(shiftR_.processLinked(feedR, shiftL_.lastSplice())) *
gain;
static_cast<double>(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<double>(pcm[i0]) : 0.0) +
((i1ok ? static_cast<double>(pcm[i1]) : 0.0) -
(i0ok ? static_cast<double>(pcm[i0]) : 0.0)) * frac;
outL = srcL * gain;
outL = srcL;
if (stereo) {
const double srcR = (i0ok ? static_cast<double>(pcmR[i0]) : 0.0) +
((i1ok ? static_cast<double>(pcmR[i1]) : 0.0) -
(i0ok ? static_cast<double>(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<double>(filter_.process(0, static_cast<float>(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<double>(filter_.process(1, static_cast<float>(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ₙ*(1w) + ref*w = out*(11) + 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.
//
+66 -18
View File
@@ -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<std::uint8_t>& 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<std::uint8_t>& out, const VelocityCurve& curve) {
const std::vector<VelocityPoint>& pts = curve.points();
putLE(out, static_cast<std::uint32_t>(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<std::uint8_t>& 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<VelocityPoint>& pts = p.velocityCurve.points();
putLE(out, static_cast<std::uint32_t>(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<double>(f.settings.cutoffNorm)));
putLE(out, doubleToBits(static_cast<double>(f.settings.resonanceNorm)));
putLE(out, doubleToBits(static_cast<double>(f.settings.morphNorm)));
putLE(out, doubleToBits(static_cast<double>(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<VelocityPoint> 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<float>(bitsToDouble(r.u64()));
f.settings.resonanceNorm = static_cast<float>(bitsToDouble(r.u64()));
f.settings.morphNorm = static_cast<float>(bitsToDouble(r.u64()));
f.settings.driveNorm = static_cast<float>(bitsToDouble(r.u64()));
f.settings.morphLaw = (r.u8() != 0) ? engine::filter::MorphLaw::HighNotchLow
: engine::filter::MorphLaw::HighBandLow;
f.modAmount = bitsToDouble(r.u64());
f.velAmount = bitsToDouble(r.u64());
f.keyTrack = bitsToDouble(r.u64());
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{};
+25 -7
View File
@@ -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
+13
View File
@@ -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;
}
+16
View File
@@ -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
+7
View File
@@ -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)
+100
View File
@@ -0,0 +1,100 @@
// deck_groups.cpp — see deck_groups.h. Pure data; no host types.
#include "core/instrument/ui/deck_groups.h"
#include <utility>
namespace reasampler::instrument::ui {
namespace {
int id(DeckParam p) { return static_cast<int>(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<DeckGroupDesc> sampleDeckGroups(PlayMode playMode) {
std::vector<DeckGroupDesc> 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
+82
View File
@@ -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 <vector>
#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<DeckGroupDesc> sampleDeckGroups(PlayMode playMode);
// The deck's BIPOLAR knob law: 0.5 of the knob's travel is zero depth, the ends are -1 and
// +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
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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})
+92 -64
View File
@@ -12,11 +12,13 @@
#include <string>
#include <vector>
#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<float>(clamp01(value)); break;
case ParamControl::kFilterCutoff:
play.filter.settings.cutoffNorm = static_cast<float>(clamp01(value)); break;
case ParamControl::kFilterQ:
play.filter.settings.resonanceNorm = static_cast<float>(clamp01(value)); break;
case ParamControl::kFilterDrive:
play.filter.settings.driveNorm = static_cast<float>(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<double>(processor_->previewVelocity()) / 127.0;
}
std::vector<DeckGroupDesc> 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<DeckGroupDesc> out;
{
DeckGroupDesc amp;
amp.id = kGroupAmpEnv;
amp.captionWidth = 78;
amp.captionToggle = {static_cast<int>(ParamControl::kPlayMode), 44};
if (play.playMode == PlayMode::Gate) {
amp.cellIds = {static_cast<int>(ParamControl::kAttack),
static_cast<int>(ParamControl::kHold),
static_cast<int>(ParamControl::kDecay),
static_cast<int>(ParamControl::kSustain),
static_cast<int>(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<int>(ParamControl::kTrigFadeIn),
static_cast<int>(ParamControl::kTrigLength),
static_cast<int>(ParamControl::kTrigFadeOut), -1, -1};
}
out.push_back(std::move(amp));
}
{
DeckGroupDesc pitch;
pitch.id = kGroupPitch;
pitch.captionWidth = 38;
pitch.captionToggle = {static_cast<int>(ParamControl::kPitchEngine), 48};
pitch.cellIds = {static_cast<int>(ParamControl::kKeyTrack)};
out.push_back(std::move(pitch));
}
{
DeckGroupDesc penv;
penv.id = kGroupPitchEnv;
penv.captionWidth = 58;
penv.captionToggle = {static_cast<int>(ParamControl::kPitchEnvEnable), 32};
penv.cellIds = {static_cast<int>(ParamControl::kPitchEnvAttack),
static_cast<int>(ParamControl::kPitchEnvDecay),
static_cast<int>(ParamControl::kPitchEnvDepth)};
out.push_back(std::move(penv));
}
{
DeckGroupDesc voice;
voice.id = kGroupVoice;
voice.captionWidth = 38;
voice.captionToggle = {static_cast<int>(ParamControl::kVoiceMode), 40};
voice.cellIds = {static_cast<int>(ParamControl::kVoiceCount)};
voice.rowToggle = {static_cast<int>(ParamControl::kMonoTrigger), 44};
out.push_back(std::move(voice));
}
{
DeckGroupDesc master;
master.id = kGroupMaster;
master.captionWidth = 46;
master.cellIds = {static_cast<int>(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<ParamControl>(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<double>(filterQFromNorm(play.filter.settings.resonanceNorm)));
break;
case ParamControl::kFilterDrive:
snprintf(buf, sizeof(buf), "%.2f",
static_cast<double>(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;
+33 -7
View File
@@ -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<ParamControl>(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<int>(ParamControl::kPitchEnvAttack) ||
hit.id == static_cast<int>(ParamControl::kPitchEnvDecay) ||
hit.id == static_cast<int>(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);
+2 -11
View File
@@ -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.
+41 -16
View File
@@ -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 <string>
#include <vector>
#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<ParamControl>(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 =
+8 -31
View File
@@ -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<DeckGroupDesc> 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.