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

## Scope

The ReaSampler 9000 instrument's pure, REAPER-free, VST3-free, unit-tested core, in four
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, the Trigger
  play-span formula).
- **`note/`** — the programmed capture-signal model: musical-division note length, tempo
  resolution, and anchored start/end offsets — the one record and resolver a
  capture-signal popup and the offline bake read from, so they cannot diverge.
- **`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, cycling indefinitely until note-off, with a
  user-parameterized pre-seam crossfade at the reset (`engine/loop/`). 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; note-off is ignored (the voice plays through, no sustain loop). Frame span
  `[startFrame, playEnd)` where `playEnd = startFrame +
  round(lengthFraction·(frames − startFrame))`. The amplitude over that span is the staged
  **AHD** (below), not a fade pair. **Note-off in Trigger is a no-op** — choke-on-note-off
  is held/out of scope (fork S15-F1).

  > **Superseded, do not reintroduce:** Trigger's amplitude was once a fade-in/unity/
  > fade-out shape with its own equal-power curve and its own `fadeInFrames`/`fadeOutFrames`
  > pair, clamped so the two fades fit the span. That is retired — one staged-envelope
  > design now covers what were two mechanisms. A saved instance's fades lift onto the AHD
  > at the codec boundary (attack ← fade-in, decay ← fade-out, hold ← the remainder).
- **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 — AHD, off by default.** A pitch-offset curve rising to `peakSemitones`
  over attack, holding, then decaying to 0, 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. Its hold fraction defaults to 0,
  which is exactly the attack-decay shape it grew out of. 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.

### Live parameter delivery — a knob moves the note already sounding (settled 2026-07-30)

Daniel's ruling, verbatim: *"hell no, I was going to bring that up for the other envelopes. We
must live compute, latching the parameters at note on is not acceptable. long term these will be
automatable parameters."* It rejects the precedent, not one instance of it.

- **Which controls are live is ONE decision, recorded in ONE place** — `isLiveDeckParam` and
  `liveCommitFor` (`ui/deck_groups`), whose header is THE home for which controls are live and
  why each exclusion is excluded — see there rather than restating the list here.
- **Ownership sits ABOVE every snapshot.** `SampleData::live` is a NON-OWNING pointer to the one
  block the shell owns per instance. The member-ordering constraint that enforces it, and why,
  are recorded at `liveParams_` in `shell/instrument/reasampler_processor.h`. A drain voice
  tracking the knob is the DESIRED behaviour — it is the note the user is hearing.
- **Null is the bare engine.** `live == nullptr` is byte-identical to the pre-live core, which
  is why `sampler_core`'s regression baselines needed no change.
- **Observation is at block boundaries, never per frame.** `VoiceEngine` reads the seqlock once
  per `render()` and once per note-on; the per-sample path gained three predicted branches (the
  voice's filter-ramp check and each envelope smoother's active check), all false at rest, and
  no indirection.
- **A fresh note SNAPS, a sounding one holds φ.** They are different entry points on purpose
  (`snapLive` vs `applyLive`): a voice that has rendered nothing has no phase to hold, and the
  φ rule reads its stage-0 position under a stale zero-length stage as a completed stage. One
  function serving both silently discarded every newly-dialled attack.
- **The mid-stage rule is HOLD NORMALIZED STAGE POSITION** (Daniel's pick among six candidates):
  φ = elapsed/duration is held across a stage-time change, so the level is continuous by
  construction and the remainder takes its share of the new duration. Stated over normalized
  position rather than output level ON PURPOSE, so a per-segment curve exponent composes with
  it as a pure map of φ. Recomputing from absolute elapsed (which steps) is the rejected
  alternative — do not reintroduce it.
- **Two genuine level steps are smoothed, not ruled away**: a sustain level moved while the
  voice holds it, and a stage duration dialled to exactly zero mid-stage. Both are absorbed by
  the envelope's own bounded offset smoother.

### 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.

### The envelope overlay — one graphical surface, every envelope (S-VIEW, extended)

The overlay draws ONE envelope over the Sample view's hero waveform, and WHICH one is a
transient editor choice: each envelope deck (amp, pitch, filter) carries a corner radio, at
most one is overlay-active, and **none is a valid resting state — the editor opens there.**
Never persisted; it selects what is drawn, not what is played.

**The overlay is directly editable — draggable nodes (SETTLED, S-VIEW-F2), plus a round
mid-segment knot per sloped stage that sets that stage's curve exponent.** A node drag, a
knot drag and the deck knobs are surfaces onto ONE model: all three read/write the same
fields of the one parameter set, so an edit on any of them re-lays the others — one source
of truth, structural (re-read-every-paint), never a listener chain. Every drag is
range-clamped to the same per-param min/max the knobs enforce, so no drag can produce a
param a knob couldn't. Two pure modules split the forward (draw) and inverse (edit) maps —
see `envelope_overlay` and `envelope_edit` in Modules below.

**Every envelope is EITHER staged or drawn, and both states persist.** Each of the three
(amp, pitch, filter) carries a `SplineEnv` — a mode plus a contour over NORMALIZED sample time —
beside its staged parameters. Switching modes converts and discards nothing: the inactive state
stays saved but inert, and round-tripping restores the other mode's shape untouched. The
consequences, each with one home:

- **Gate is unavailable while any EG is drawn.** A contour is a pure time function over the full
  sample length, which IS the Trigger/one-shot model. `splineActive` (`play_params.h`) is the
  predicate; `enforceGateUnavailableWhileDrawn` (`play_params.h`) is the one enforcement of it,
  called by both `resolvePlay` (`sample_map.cpp`) on the way to the engine and the editor's
  `applyControl`, so the two callers cannot drift. The editor's Gate segment refuses and paints
  Disabled off the same `splineActive` predicate.
- **A drawn envelope's staged segment knobs go inert** — drawn-but-dead, never removed, never
  hidden — including their inner curve dials, which are reached through their outer cell.
  `deckKnobInert` (`ui/deck_groups`) is the one place that list lives. The DEPTH knobs (pitch
  peak, filter mod amount) stay live: they scale whichever shape is active.
- **Normalized is what makes a contour length-independent.** There are no stored seconds to
  rescale, so a different-length capture replays the same shape proportionally.
- **The contours sit on `PlayParams`/`PlaySeconds` directly, not inside the three envelope
  structs.** Those are copied whole into the live block, which must stay trivially copyable
  (`live_params.h`) — and a contour is not a live control anyway: like the velocity curves it
  travels by reload.

**Which shape a STAGED envelope takes is decided by the play mode, not by what it modulates:**
pitch is always AHD; amp and filter are AHDSR in Gate and AHD in Trigger. Both mode shapes
are STORED per envelope, so flipping modes cannot lose either mode's dialled values (the
migration case forces it: an old instance carries both its AHDSR values and its Trigger
fades, and one shared set could not preserve both modes' prior sound).

**And which LAYOUT an envelope takes follows from whether it has a sustain stage** — the
same rule, applied once: an AHDSR right-anchors its release (the end point is fixed at the
canvas edge and release is dragged from its top node), a sustain-less AHD maps 1:1 onto the
waveform's time axis. The two policies coexist rather than merge; the 1:1 mapping only means
anything for a trigger shape.

### 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 curves** — three of them (amp, pitch, filter), all per-instance, edited from ONE
  deck group. The amp curve is 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. The pitch and filter curves are bipolar and off by default
  (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`/`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, `AhdEnvelope` the sustain-less Attack/Hold/Decay, `PitchEnvelope` the AHD pitch offset), CONCRETE and fully header-inline. Never give them a common base or a virtual `tick()`: they are called per-voice-per-sample. Also home to `fitAhd`/`ahdLevelAt`, THE span split and shape every sustain-less envelope shares. A voice carries two of each shape — the amp's and the filter's — and its play mode picks which pair it reads. `AdsrEnvelope`/`PitchEnvelope` own `applyLive` (the φ-holding mid-stage rule), its fresh-note peer `snapLive`, and `StepSmoother`, the bounded offset that absorbs the level steps φ cannot cover; `AhdEnvelope` is POSITIONAL (evaluated at a source offset, not ticked), so it has no phase to hold and smooths a live reshape instead.
  - `live_params.h` / `live_params.cpp` — the live-parameter block: `LiveValues` (the plain, trivially-copyable bundle the audio thread observes), the single-writer `LiveParams` seqlock that publishes it without a lock or a torn read, `foldLive` (the ONE derivation from `PlayParams` — every publisher goes through it so the two representations cannot drift), and `ValueRamp`, the per-frame glide whose EXACT termination is what lets the filter's equality-compare cutoff skip re-engage. Links no engine: the block is a value the voice observes, not a thing the engine owns.
  - `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`. **Documented ~600-line-ceiling exception** (root `CLAUDE.md` structural heuristic 1): `voice.h` sits over the ceiling because `advanceFrame`'s RT-inline constraint forbids the seam a split would need — a documented exception, not silent overshoot.
  - `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.
- `engine/loop/` — the sustain loop's ONE validity/clamp fold (`resolveLoop`) plus its pre-seam crossfade geometry and the editor's default handle span; see `engine/loop/CLAUDE.md`. The voice folds it once at note-on; the crossfade weight is header-inline because it rides the per-sample read.
- `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` — THE monotone spline, shared by every consumer: the three velocity transfer curves and the three spline EGs. `VelocityCurve` is evaluated as ONE OR MORE Fritsch–Carlson monotone cubic Hermite splines joined at its HARD points — a hard knot is a sub-curve boundary for tangent purposes (exactly what the point array's own ends already are), so the two adjacent segments meet at their natural angle instead of a shared derivative and the no-overshoot guarantee holds PER SEGMENT rather than globally. Points are smooth by default; the ceiling is `kMaxCurvePoints` = 128, a MUSICAL bound (long rhythmic phrases, ~two points per articulation event) and not a performance one — **do not lower it**. `eval(velocity)` is the COLD reader, called once per note-on or once per drawn pixel column; `SplineCursor` is the RT one, an indexed segment search plus one Hermite evaluation with the segment and its tangents cached across samples. Both share the same `segmentTangents`/`hermiteAt` free functions, so there is one spline and not two. It carries its own y `CurveDomain`: UNIPOLAR [0,1] is the amp's GAIN, defaulting to `flat()` (y=1, every velocity→unity — a deliberate non-back-compat replacement of the old fixed `velocity/127` path, Daniel-approved); BIPOLAR [−1,1] is the signed modulation shape for pitch and filter, defaulting to `zero()` so velocity modulates neither until a curve is drawn. A bipolar curve does not imply the absence of a depth beside it: the filter keeps its `velAmount` knob and the two compose multiplicatively (`velAmount × curve.eval(v)`, `play_params.h`), while the pitch curve's throw is the fixed `kVelocityPitchRangeSemitones`.
- `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…v13), 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. Every tail since is a strict suffix on the same discipline — v10 the staged curves, v11 the loop crossfade, v12 the velocity→pitch curve, v13 the dual Staged/Spline state (the three contours, plus hard-flag tails for the three velocity curves — their v7/v9/v12 blocks are frozen at 16 bytes/point and had no room for a per-point flag). v12 also RE-TAGS the y DOMAIN of one frozen slot inside the v9 filter tail — its velocity curve reads bipolar from v12 on, unipolar before — which needs no version branch, because a pre-v12 curve's y values are already valid bipolar ones; every other filter slot, `velAmount` included, keeps its meaning.
- `params_payload` — the PARAMS-PAYLOAD half of that codec, split from the envelope half on the axis the format already has: the payload carries its own version and grows independently, so the two version ladders are two responsibilities. An INTERNAL seam — the public entry points stay `serialize`/`deserializeComponentState`. The prose ladder and every version constant stay in `component_state_io.h`, their one home.
- `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` — the shared Trigger play-SPAN formula: how the stored %-length becomes the source-frame span the voice plays and the overlay draws over, threading `startFrame` correctly. (Its fade frames↔fraction converters retired with the fade pair itself.)

### `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 — including `kEditorMinWidth`/`kEditorMinHeight`, the editor's client-area floor, which IS its default size (the shell's `checkSizeConstraint` and opening `ViewRect` both read it; the face grows, never shrinks below what the stack is laid out for). 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, 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. Also `previewGlyph`, the preview button's play triangle — three vertices for one filled-triangle draw, so the button's label needs no font metric and no image asset.
- `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, plus `markerHandleRect` — a top-strip grab tab distinct from a marker's full-height column, so two markers that share a frame stay independently grabbable (the column goes to the first in draw order; the tab, asked first, resolves the other).
  - **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. A group carries TWO caption-toggle slots, laid right-to-left: the second exists because a group whose knob row is wider than its caption row has caption slack a toggle can occupy for free, and the deck has six pixels of headroom on its first row at the editor's floor width — a `rowToggle` would widen the GROUP and wrap the deck to a fourth row, past what the minimum window holds. **A group's cell run is a RESERVED WIDTH, not a fixed cell size**: a `-1` id reserves one cell's width without a cell, and the cells present divide the whole run between them at one uniform integer width (residue in symmetric end margins). That is what lets a mode flip drop controls from a face — Trigger's AMP and FILTER ENV lose their Sustain/Release stages — without either reflowing the deck or leaving dead slots in the box; a face with fewer controls simply gets roomier cells. Do not reintroduce fixed-width cells with blank slots.
- `deck_groups` — also home to `isLiveDeckParam` and `liveCommitFor`, the editor's whole commit-tier routing decision (see "Live parameter delivery" above), and to `OverlayEnv` + `nextOverlaySelection`/`overlayEnvEnabled`/`overlayEnvInert`, the whole overlay-selection state machine (exclusivity, the none resting state, and which selections a disabled or DRAWN group makes inert); 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 velocity/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. Also home to `CurveTarget` + `curveTargetFor` — the VELOCITY group's three cells are popup openers, not dials, and that predicate is the ONE place they are named, so paint, hit-test routing and the popup's title all agree. MASTER is reserved for post-voice-mixer concerns, which is why the curves sit in their own group immediately left of VOICE rather than there.
- `spline_edit` — THE point-editing grammar, and the one place it is written down: left-click grabs a node and adds one in empty space, right-click deletes, control-click toggles hard/smooth. Both spline consumers — the velocity-curve popup and the spline EG overlay — route their mouse-down through `resolveSplineEdit`, so the two cannot drift into two grammars. The endpoint and point-count rules are NOT restated here: `deletePoint` and `addPoint` own them, and the caller applies the resolved action to the curve. Also home to `splineOverlayBox`, the contour's mapping box inside the waveform overlay — the FULL area, no inset, so the drawn contour stays 1:1 with the sample's time axis.
- `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 staged-envelope→polyline geometry for the Sample-view overlay (read from `envelope_overlay.h`): maps a `StageEnvelope` to a polyline inside a rect under whichever of TWO layout policies its `EnvKind` selects — an AHDSR draws a bounded param-domain schematic with its release RIGHT-ANCHORED to the canvas edge, an AHD draws 1:1 over the waveform's own time axis — plus a round mid-segment knot on every sloped stage that has a duration. Every vertex clamped in-canvas. Shares the `EnvNode`/`StageEnvelope`/`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 and their curve knots (read from `envelope_edit.h`): `nodeAtPoint` resolves a grab to the nearest node within a pick radius (Chebyshev distance, draw-order tie-break, knots appended last so a coincident endpoint handle wins); `resolveNodeDrag` maps a pixel delta since grab to a new `StageEnvelope` under the same caller-supplied per-param clamp bounds the knobs use — a drag can never produce a param a knob couldn't. Mirror of `card_drag`/`waveform_view`; the inverse of `envelope_overlay`'s params→polyline forward map, so node-drag, knot-drag and knob-edit read/write one shared model and can never diverge.

## Gotchas

- **An AHDSR's overlay x-axis is schematic, not PCM-aligned** — it does NOT line up with the waveform under it; only a sustain-less AHD's x-axis is wall-clock/PCM-aligned. Don't assume a gated envelope's curve is time-accurate against the sample.
- **An AHD's Hold is a FRACTION of what attack and decay left, never a time.** That is the whole reason A+H+D ≤ span holds by construction; adding a clamp on the sum, or re-expressing Hold as a duration, reintroduces the overflow the fraction exists to prevent.
- **`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.
- **A filter envelope only advances while its depth is non-zero.** `tickFilterCutoff`'s exact
  skip at `modAmount == 0` skips the envelope tick along with the solve, so dialling depth up
  mid-note starts the envelope from the note's stage-0 position rather than from where it would
  have been. Its step smoother is frozen with it — an absorbed step sits in the offset and
  emits when depth is next dialled up (bounded, and scaled by a depth ramping from 0).
  Continuous either way (the contribution starts at 0), and keeping the skip is what holds the
  at-rest per-sample path byte-identical — but don't read a live depth move as "resuming" an
  envelope that was never running.
- **A live edit leaves the snapshot's own `sample.play` stale, on purpose.** The block, not the
  snapshot, is the audio thread's source; a new voice latches the stale copy and is corrected by
  `snapLive` before its first frame.
- **`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.
