# src/core/instrument — pure VST3-instrument core (engine / map / ui)

## Scope

The ReaSampler 9000 instrument's pure, REAPER-free, VST3-free, unit-tested core, in three
subdirectories:

- **`engine/`** — the polyphonic voice engine, the one set of play params, pitch shifting,
  velocity curve, and master-gain taper math.
- **`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

### The three locked decisions this spec assumes (settled 2026-07-26)

- **D1 — native VST3.** Not JSFX. Full sampler sophistication, clean integration, and
  access to the REAPER VST-host bridge.
- **D5 — Windows-only, VST3-only, REAPER-only.** No cross-platform DSP/build/signing
  matrix, no multi-format wrapper, no standalone-in-other-hosts concern.
- **D6 — two products, tightly integrated.** A separate artifact, but not a divorced
  file-only companion: via the VST-host bridge it reads the live `"reasampler"` project
  ext-state and is project-aware. (The bridge mechanism itself is documented in
  `src/core/wire/CLAUDE.md`.)

### The two seams (audio via files, mapping via live state)

- **File seam (audio, permanent).** The sample **audio** is the on-disk 32-bit-float
  WAVs — project-relative, travelling with the `.rpp`. The instrument resolves those
  paths the same way `persist` does (a shared convention, not a re-implementation).
  There is no live PCM stream across the bridge, by design.
- **Live-state seam (the mapping, via the bridge).** For everything that is not raw
  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:

- **Bank intrinsics (facts about the captured file) live on `Sample`.** Root note (the
  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`).
- **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, 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.

- **The bank is one source; the instrument is another view of it (never a fork).** The
  instrument is a pure consumer of the bank — it does not copy samples, does not own a
  private sample store, and does not mutate the bank.
- **`Sample` field additions are additive and lossless.** No existing `Sample` field
  changes; no `BankIndex` behavior changes.
- **Relative-paths-only survives.** The instrument resolves audio via the project-relative
  machinery; it introduces no absolute paths.

### Channel mode — current reality

**Current reality (root `CLAUDE.md`, GA post-launch pass): the output bus is
permanently stereo.** `ChannelMode` is decode-only; the dynamic mono↔stereo bus
renegotiation (`setBusArrangements` per-instance toggle) has been deleted. Channel mode
auto-defaults from the loaded capture's channel count via a pure `channelModeFor` helper,
gated by a persisted `channelModeExplicit` flag (`ComponentState` v9). Mono source +
stereo mode → dual-mono (same signal both channels, centered); stereo source + mono mode
→ downmix (existing decode-side policy).

> **Superseded design, do not reintroduce:** an earlier "Channel mode — mono |
> stereo (D-E)" design specified a per-instance toggle that **dynamically
> renegotiates the REAPER audio bus** via `setBusArrangements`/`getBusArrangement`
> (the instrument reporting mono or stereo per instance and REAPER's routing
> following). That dynamic-bus-negotiation design was superseded by the GA fix
> above; root `CLAUDE.md` is current and wins.

### Sampling modes — Gate vs Trigger, pitch engine, pitch envelope (S15/S16 — settled, landed)

Daniel's directive (2026-07-26, verbatim): *"Sampling mode: Trigger vs Gate. Gate has an
AHDSR envelope. Trigger has fade in, % length, and fade out. Both modes have modifiable
start point, Gate has modifiable loop points too. In addition to amp env, there will be a
pitch envelope/curve (AD?) which is off by default."*

- **Gate — classic held note.** Note-on enters the amp envelope; note-off enters
  release; a sustain loop applies for held notes. Envelope is **AHDSR**: `0→1` over
  attack, hold at 1 over `holdFrames`, `1→sustain` over decay, hold sustain until
  note-off, `level→0` over release. `holdFrames == 0` is exactly the pre-Gate ADSR — a
  back-compat degenerate.
- **Trigger — one-shot drum-pad.** Note-on fires playback of a defined `%` of sample
  length with a fade-in and fade-out ramp; note-off is ignored (the voice plays through,
  no sustain loop). Frame span `[startFrame, playEnd)` where `playEnd = startFrame +
  round(lengthFraction·(frames − startFrame))`; amplitude ramps `0→1` over
  `fadeInFrames` at the head and `1→0` over `fadeOutFrames` anchored to `playEnd`; fades
  clamp so `fadeInFrames + fadeOutFrames ≤ play length`. Fade curve is equal-power
  (constant-power sin/cos). **Note-off in Trigger is a no-op** — choke-on-note-off is
  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 (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
  source rate while a pitch shifter transposes the output — musically right for
  tempo-locked loops/phrases; **the engine default leans Preserve** (fork S16-F1).
  Contract for Gate's sustain loop under Preserve: *loop the source, shift the output*
  (loop points stay source-frame facts). `WDL_Resampler` is **not** a Preserve engine (it
  is a resampler that couples duration) — never wire it as the duration-preserving path.
- **Pitch envelope — AD, off by default.** A short attack-decay pitch-offset curve
  (`peakSemitones` over `attackFrames`, decaying to 0 over `decayFrames`) riding on top of
  whichever pitch engine; a zero attack gives a pure percussive pitch drop. **Off by
  default** — a regression that applies pitch modulation when the envelope is disabled is
  a bug. Under Varispeed the offset is a per-frame multiply of `ratio_`; under Preserve it
  is added to the shifter's shift amount.
- **Preserve RT discipline.** The shifter pre-warms at voice-allocation; no allocation in
  `process()` in steady state. **Note (supersedes an earlier framing):** the
  shifter's onset latency (~25 ms, half-window) was once described as "an
  accepted property, not a defect." Root `CLAUDE.md`'s GA2 pass **eliminated** that onset
  latency (ring buffer primed with the actual upcoming source at note-on instead of
  zero-filled, so Preserve now speaks on frame 0, matching Varispeed) — a
  cold-started/un-pre-warmed shifter producing a click or smear remains a bug.
- **S15/S16 stay channel-count-agnostic.** The mode/envelope logic is per-frame amplitude
  and read-rate, independent of the stereo channel dimension — any S15/S16 code that
  assumes a fixed (mono) channel count rather than operating per-frame pre-mix is a bug.
- **S15/S16 are Tier 0–1 engine features, not Tier 2/3** — do not let the held Tier-2
  feature list (velocity layers / round-robin / filter work) drive their build shape.

### Non-goals / guardrails (instrument-specific; repo-wide invariants live in root CLAUDE.md)

- **No cross-platform / multi-format.** Windows-only, VST3-only, REAPER-only (D5). Do not
  add an AU/AAX/VST2/CLAP wrapper, a mac/Linux build, or a standalone host target.
- **The pure core stays REAPER-free *and* VST3-free.** Any VST3 or REAPER type leaking
  into the voice engine / envelope / keymap / repitch module is a bug (the D3 split).
- **Channel mode is a performance choice, not a bank fact.** Never written to `Sample` or
  the bank.
- **Do not spec Tier 2/3** from this directory. Tier 2 is held, Tier 3 is
  optional-forever; don't let their feature lists drive Tier 0–1's build shape.

### Envelope overlay + draggable nodes (S-VIEW, settled 2026-07-27, landed)

The amp envelope is drawn as a curve over the Sample view's hero waveform at the shared
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 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.

### Parameter ownership and persistence (D-B)

- **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** — 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).

## Modules

### `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.
  - `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).
- `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` — 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.
- `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.

### `ui/`

- `editor_geometry` (`core/instrument/ui`) — the shared geometry VOCABULARY every instrument UI module speaks: the `core::ui::Rect` alias, `contains()`, and `OverlayArea` (a one-field `Rect` wrapper, no implicit conversion from `Rect`). Header-only (an INTERFACE CMake target), so it carries no layout of its own.
- `sample_bands` — **THE band-stack allocator**, and the only module that owns the Sample face's vertical inventory: three bands top-to-bottom (CHROME toolbar+control row / WAVEFORM elastic, floored at two stacked lanes / DECKS bottom-anchored at the knob deck's own wrapped height), plus the waveform band's lane split (`waveformLanes` takes a resolved `LaneSplit`, not a raw bool — only `waveformSurface` folds the source-channel-count decision in). A shared READ-ONLY surface for every band owner — a band's interior module lays out inside the rect it is handed and never re-allocates the stack.
- `sample_chrome` — the CHROME band's interior: the toolbar row (title + the whole right-anchored control run — preview, velocity knob cell, curve button, channel toggle, Browse) over the strip row, which the piano strip owns outright. The title takes what the run leaves; the strip takes its whole row, inset only by the shared band pad so it lines up with the waveform band beneath.
- `keyboard_strip` — piano-keyboard strip: true white/black key geometry (whites tiled at one width, blacks overlaid at one width and height, straddling their boundary), hit-test resolving black-over-white by zone, root-marker rect, the absolute-position drag resolver, and MIDI note naming under the C4 convention. **Same-class keys are one integer width by construction; the residue of an indivisible band width (`w % 75`, up to 74 px) lands in symmetric end margins, never in a key** — uniform widths and gap-free edge-to-edge tiling cannot both hold, and uniformity wins.
- `waveform_view` — the WAVEFORM band's interior: `waveformSurface` resolves the drawn lane(s) (two stacked lanes, L over R, only when the mode is stereo AND the source has a second channel — a mono source under stereo mode is dual-mono and draws one lane) plus **the** overlay area, and `laneEnvelope` splits one multi-channel envelope pass per lane. Also maps frame span linearly across a rect; generic named draggable markers with drag-delta resolver, clamp, and zero-crossing snap.
  - **Overlay contract (consumed by later waveform work).** `WaveformSurface::overlay` — equivalently the standalone `waveformOverlayArea(band)` — is the FULL band in both modes. Everything riding the waveform (the amp-envelope trace and its node handles, the start/loop markers, the loop region) draws ONCE into it, spanning both stacked lanes; hit-testing resolves against the same area so a grab in the lower lane reaches them. Anything drawn or hit-tested per lane is a duplicate and a defect — structurally enforced: `overlay` is the distinct `OverlayArea` type (`editor_geometry`), not `Rect`, so every overlay-consuming API (`frameToX`/`markerAtPoint`/`resolveDragFrame`, `envelope_edit`'s `nodeAtPoint`/`resolveNodeDrag`, `envelope_overlay`'s `buildEnvelopePolyline`) rejects a lane rect at compile time rather than silently accepting one.
- `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.
- `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.
- `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.

## Gotchas

- **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 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
  for both — do not reintroduce either superseded design.
- **`keyboard_strip`'s width-uniformity guarantee is client-pixel only.** Its test sweep
  covers client-pixel widths (including multiples standing in for larger client areas);
  nothing in the instrument implements `IPlugViewContentScaleSupport`, so host-side DPI
  scaling of the plugin window — which would resample the uniform integer key widths at the
  physical-pixel level — is unverified.
