Retire the zone system: one capture = one parameter set, and re-seam the engine and Sample face into bands

This commit is contained in:
2026-07-30 07:15:54 -04:00
parent a689fb75eb
commit 8d4ccbf841
61 changed files with 5416 additions and 8008 deletions
+60 -39
View File
@@ -5,16 +5,17 @@
The ReaSampler 9000 instrument's pure, REAPER-free, VST3-free, unit-tested core, in three
subdirectories:
- **`engine/`** — the polyphonic voice engine, per-zone play params, pitch shifting,
- **`engine/`** — the polyphonic voice engine, the one set of play params, pitch shifting,
velocity curve, and master-gain taper math.
- **`map/`** — the zone/keymap payload, the cross-artifact `ComponentState` codec, and the
small pure helpers the engine/shell share (bank-generation sync, bridge-read
marshalling, note-name parsing, Trigger frame↔fraction conversion).
- **`ui/`** — pure editor geometry/hit-test modules (layout, waveform, keyboard strip,
capture browser, param controls, envelope overlay/edit). These are geometry-and-math
only; the LICE draw + REAPER/VST3 plumbing is the `shell/instrument` editor shell,
**out of scope for this file** (owned by a parallel dispatch), along with the VST3
processor, `reaper_bridge`, `reasampler_embed`, and `vst_entry`.
- **`map/`** — the capture resolution + `SampleData` build, the cross-artifact
`ComponentState` codec, and the small pure helpers the engine/shell share
(bank-generation sync, bridge-read marshalling, note-name parsing, Trigger
frame↔fraction conversion).
- **`ui/`** — pure editor geometry/hit-test modules (the band-stack allocator and its band
interiors, waveform, keyboard strip, capture browser, param controls, envelope
overlay/edit). These are geometry-and-math only; the LICE draw + REAPER/VST3 plumbing is
the `shell/instrument` editor shell, along with the VST3 processor, `reaper_bridge`,
`reasampler_embed`, and `vst_entry`.
## Invariants
@@ -39,6 +40,21 @@ subdirectories:
audio — the bank index, the mapping, which project is active — the instrument reads
the live `"reasampler"` ext-state via the bridge.
### One capture = one parameter set
The instrument holds ONE loaded capture and ONE set of playback parameters governing it
across the whole keyboard. There are no zones, no per-zone divergence, and no keymap of
captures: every playback parameter edits in exactly one place, and no gesture can express
per-zone divergence. The root note survives as a first-class parameter of that one set.
- **No key-range concept.** The loaded capture answers every note 0..127, repitched from
its root, with key-tracking applied. A user-settable low/high playable range is
re-addable later as two ordinary parameters if it is ever missed.
- **Migration is adopt-the-first-zone.** A saved multi-zone instance lifts by taking zone
one's capture and zone one's parameters; the rest drop, touching no file and no bank
entry. Single-zone instances lift losslessly. The sounds-identical bar is deliberately
relaxed for a genuinely multi-zone instance.
### The seam fields — what becomes a bank intrinsic (D-B, settled 2026-07-26)
The split model is the settled answer, mirroring the capture/placement separation:
@@ -47,20 +63,18 @@ The split model is the settled answer, mirroring the capture/placement separatio
MIDI note the sample was recorded at) and loop points (sustain-loop start/end for held
notes) are facts about the file, added as an additive field extension (same shape as
`provenance`).
- **The performance map (a creative arrangement) lives in the instrument.** Key zones,
velocity layers, round-robin groups, amplitude envelopes, and per-sample tuning/gain
trim are a performance choice, not a fact about a file — they belong to the instrument,
not the bank. This "who owns which field" rule (D-B) governs every performance-map
field added since, including play mode/AHDSR/Trigger params (S15), pitch engine mode
and pitch envelope (S16), key-tracking, preview velocity, and the velocity curve
(S-VIEW) — all are per-instance/per-zone `ComponentState`, never written to `Sample` or
the bank.
- **Performance choices live in the instrument.** Amplitude envelopes and per-sample
tuning/gain trim are a performance choice, not a fact about a file — they belong to the
instrument, not the bank. This "who owns which field" rule (D-B) governs every parameter
added since, including play mode/AHDSR/Trigger params (S15), pitch engine mode and pitch
envelope (S16), key-tracking, preview velocity, and the velocity curve (S-VIEW) — all are
per-instance `ComponentState`, never written to `Sample` or the bank.
### The pure core (D3 — the load-bearing split)
The sampler's voice engine, envelope math, key/velocity mapping, repitch/interpolation,
and keymap resolution are a pure, REAPER-free, DAW-free, unit-tested module — the mirror
of `bank_model`/`peaks`/`view_mode_model`/`bank_book`. The VST3 wrapper (the
The sampler's voice engine, envelope math, velocity mapping, and repitch/interpolation are
a pure, REAPER-free, DAW-free, unit-tested module — the mirror of
`bank_model`/`peaks`/`view_mode_model`/`bank_book`. The VST3 wrapper (the
`SingleComponentEffect` subclass, bus setup, `process` marshalling, the `IPlugView` LICE
editor, and the bridge calls) is the thin shell — the only part that touches VST3 or
REAPER at all. Any VST3 or REAPER type leaking into this core is a bug.
@@ -112,7 +126,7 @@ pitch envelope/curve (AD?) which is off by default."*
held/out of scope (fork S15-F1).
- **Both modes: modifiable start point.** Playback begins at `startFrame` (clamped `0 ≤
startFrame < frames`). Gate additionally has modifiable loop points; Trigger has none.
- **Pitch engine — Varispeed vs Preserve (per-zone toggle, S16).** Varispeed (current/
- **Pitch engine — Varispeed vs Preserve (S16).** Varispeed (current/
classic path): `ratio_ = pitchRatio(note,root)`, `readPos_ += ratio_` with linear
interp — resampling that couples pitch and duration; cheap, zero-latency, musically
right for drums/one-shots. Preserve (duration-preserving): the read advances at the
@@ -157,25 +171,25 @@ The amp envelope is drawn as a curve over the Sample view's hero waveform at the
time base — Gate → the AHDSR shape, Trigger → the fade-in/unity/%-length/fade-out shape
anchored to `playEnd`. **The overlay is directly editable — draggable nodes
(SETTLED, S-VIEW-F2).** Dragging a node and the existing sliders are two surfaces onto
one model: both read/write the same zone envelope fields, so a drag updates the params,
the sliders reflect them live, and a slider edit re-lays the nodes — one source of truth,
structural (re-read-every-paint), not a listener chain. Nodes are monotonic in time (a
node cannot be dragged past its neighbours) and range-clamped to the same per-param
min/max the sliders enforce, so node-drag can never produce a param the slider couldn't.
Two pure modules split the forward (draw) and inverse (edit) maps — see `envelope_overlay`
and `envelope_edit` in Modules below.
one model: both read/write the same envelope fields of the one parameter set, so a drag
updates the params, the sliders reflect them live, and a slider edit re-lays the nodes —
one source of truth, structural (re-read-every-paint), not a listener chain. Nodes are
monotonic in time (a node cannot be dragged past its neighbours) and range-clamped to the
same per-param min/max the sliders enforce, so node-drag can never produce a param the
slider couldn't. Two pure modules split the forward (draw) and inverse (edit) maps — see
`envelope_overlay` and `envelope_edit` in Modules below.
### New performance-map parameters — ownership and persistence (D-B)
### Parameter ownership and persistence (D-B)
- **Key-tracking** — per-zone, additive/version-bumped component state, default 100%
- **Key-tracking** — additive/version-bumped component state, default 100%
(absent field on an older blob lifts to 100%, bit-identical playback).
- **Preview velocity** — a per-instance utility setting for the Sample view's
preview-trigger button (not a musical parameter of the capture); **persists across
reloads** via the instrument's own `ComponentState` (envelope-bumped), never via the
extension's `persist` ext-state module (that would make it project-global rather than
per-instance and leak an instrument concern into the extension's key space).
- **Velocity curve** — per-zone; the one non-back-compat surface in S-VIEW: an
already-saved zone with no stored curve now plays every velocity at unity under the
- **Velocity curve** — the one non-back-compat surface in S-VIEW: an
already-saved instance with no stored curve now plays every velocity at unity under the
flat-default (Option A), not bit-identical to the old linear `velocity/127` mapping —
a deliberate, Daniel-approved behavior change (see `velocity_curve` in Modules).
@@ -183,16 +197,19 @@ and `envelope_edit` in Modules below.
### `engine/`
- `sampler_core` — polyphonic voice engine with bounded stealing, user-parameterized voice count (132, default 16), `VoiceMode` Poly/Mono (last-note held-note stack, `MonoTrigger` Retrigger/Legato toggle), two-tier panic (CC 123 = all-notes-off release, CC 120 = immediate hard-stop including Trigger one-shots); per-zone `ZonePlayParams` (Gate/Trigger, AHDSR, pitch engine Varispeed/Preserve, AD pitch mod envelope), repitch/interpolation with loop-point-aware sustain. 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.
- `zone_params.h` (`core/instrument/engine`) is the sibling header split out of `sampler_core.h` (T4-14/T4-17): the per-zone play-parameter value structs (`ZonePlayParams`/`AdsrParams`/`TriggerParams`/`PitchEnvParams`) and the per-instance mode enums (`ChannelMode`/`VoiceMode`/`MonoTrigger`) the engine, the codec, and the editor all share.
- 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.
- `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).
- `master_gain` — pure dB↔linear taper math (FB1): normalized [0,1] ↔ dB ↔ linear for the post-mixer master gain control (−∞…+24 dB, norm 0 = true silence, unity ≈ 0.714). Shared by the editor knob and the processor multiply so the needle, persisted value, and audio multiply cannot drift.
### `map/`
- `sample_map` — zone payload: zones keyed by note range. **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). JSON round-trip.
- `component_state_io` (`core/instrument/map`) — the `ComponentState` envelope + zones-payload binary codec (envelope v1…v11, zones-payload v1…v7), 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 (`sampler_core`/`pitch_shift`) 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.
- `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).
- `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.
- `note_entry` — parses a raw string into a clamped MIDI note [0,127]; accepts plain decimal integers or note names (C4==60, DAW convention).
@@ -200,8 +217,10 @@ and `envelope_edit` in Modules below.
### `ui/`
- `editor_geometry` (`core/instrument/ui`) — VST3 editor layout: aliases the shared `core::ui::Rect` (+ `contains()`) rather than defining its own; owns `EditorLayout`/`layoutEditor(w,h)`, the Tier-0/Tier-1 sample-list and keymap-editor row layout/hit-test, and — hoisted here off the former `reasampler_editor.cpp` god-TU (Q-W2v, T2-06) — the r11 Sample-face band layout (`SampleBands`/`ClusterRects`/`channelToggleRects`) and the Zone-face content/legend/deck layout, so the editor shell only draws + routes.
- `keyboard_strip` — piano-keyboard strip: MIDI-note→key rect mapping, black/white key layout, hit-test, zone highlight overlay geometry.
- `editor_geometry` (`core/instrument/ui`) — the shared geometry VOCABULARY every instrument UI module speaks: the `core::ui::Rect` alias + `contains()`, nothing else. Header-only (an INTERFACE CMake target), so it carries no layout of its own.
- `sample_bands` — **THE band-stack allocator**, and the only module that owns the Sample face's vertical inventory: three bands top-to-bottom (CHROME toolbar+control row / WAVEFORM elastic, floored at two stacked lanes / DECKS bottom-anchored at the knob deck's own wrapped height), plus the waveform band's lane split. A shared READ-ONLY surface for every band owner — a band's interior module lays out inside the rect it is handed and never re-allocates the stack.
- `sample_chrome` — the CHROME band's interior: the toolbar row (title + Browse) over the control row (root strip, preview, velocity knob cell, curve button, channel toggle). The fixed run is right-anchored; the root strip takes the remainder.
- `keyboard_strip` — piano-keyboard strip: MIDI-note→key rect mapping, black/white key layout, hit-test, root-marker rect, and the drag-delta note resolver.
- `waveform_view` — waveform/marker geometry: maps frame span linearly across a rect; generic named draggable markers with drag-delta resolver, clamp, and zero-crossing snap.
- `capture_browser` — capture browser: card-grid layout + bank-filter tab strip geometry and hit-test; knows only counts and rects, draws nothing.
- `browser_scroll` — scroll + type-to-filter layered over `capture_browser`: vertical scroll offset, scrollbar thumb, thumb-drag mapping, and name-substring search.
@@ -216,7 +235,9 @@ and `envelope_edit` in Modules below.
- **Gate's envelope-overlay x-axis is schematic, not PCM-aligned** (per `envelope_overlay.h`'s FA2 contract note) — it does NOT line up with the waveform under it; only Trigger's x-axis is wall-clock/PCM-aligned. Don't assume the Gate curve is time-accurate against the sample.
- **Trigger's fade fields require a non-trivial converter, not a field copy.** `TriggerParams` (engine) stores fades as source *frames*; `AmpEnvelope` (the overlay's view struct) stores them as *fractions* of the played span. A converter is owed on both the pack (draw) and unpack (commit) directions — `trigger_seam` owns this formula; do not copy the fields directly.
- **`param_slider`'s linear slider rows are retired on the Zone panel** — per root `CLAUDE.md`'s FB2 note, the `Knob` primitive (`editor_geometry`/knob deck grammar) is now the only live consumer of that half of `param_slider` on the Zone face. Don't assume `param_slider`'s SLIDER row type is still drawn there.
- **`param_slider`'s linear slider rows are retired on the parameter surface** — per root `CLAUDE.md`'s FB2 note, the `Knob` primitive (the knob-deck grammar) is now the only live consumer of that half of `param_slider`. Don't assume `param_slider`'s SLIDER row type is still drawn.
- **The engine's per-sample path is inline ON PURPOSE.** `Voice::advanceFrame` and the three evaluators in `envelopes.h` live in headers so `VoiceEngine::render`'s inner loop — in another TU, with no LTO configured — still inlines the whole stack. Moving either out of line, or giving the evaluators a virtual `tick()`, puts a call on the hottest loop in the program.
- **The band-stack allocator is the ONLY vertical-inventory owner.** A band's interior module (`sample_chrome`, `knob_deck`, the waveform painters) lays out inside the rect it is handed. A band owner that re-derives its own top/bottom has forked the stack.
- **Two superseded designs are called out in Invariants above**: the earlier
Channel-mode (D-E) bus-renegotiation design and the earlier Preserve-onset-latency
framing in the S16 guardrails. Root `CLAUDE.md` is the current source of truth