# ReaSampler — implementation briefing

A native C++ REAPER extension that captures any arbitrary audio source into a
per-project **sample bank** (cached files + a docked grid), decoupled from the
arrange view, with keyboard/MIDI-bindable capture and placement. Built as a
precision tool: deterministic, non-destructive, no clutter.

This document is the spec. Read the existing scaffold first (`src/main.cpp` is
the REAPER<->extension contract; the pure/testable-core split in `src/mpe_model.*`
is the pattern to preserve — the MPE model is being replaced, the *discipline*
is not). Verify every REAPER API name and signature against
`vendor/reaper-sdk/sdk/reaper_plugin_functions.h` before use — the API names in
this brief are correct-by-intent but treat them as hints, not gospel, and check
argument order/types.

## The load-bearing principle

**Capture and placement are separate acts.** Capturing audio writes a file to
the bank and adds an index entry — it NEVER puts an item in the arrange. Placement
is a distinct, on-demand action (insert / drag). Any code path that auto-inserts a
capture into the timeline violates the entire point of the tool and must be
rejected in review. This single rule is why the tool exists.

## Settled decisions

- **Capture modes:** offline render (deterministic, the default) AND realtime
  record (for hardware / performed FX). Both sit behind one capture interface and
  produce identical bank entries.
- **Bank scope:** per-project, travels with the `.rpp`. Files live in a
  project-relative subfolder; the index persists in project ext state. No absolute
  paths anywhere in the index.
- **Material:** must handle full-mix/stem bounces, chops/one-shots, and
  single-cycle/wavetable grabs equally. That means exact sample-accurate bounds,
  explicit tail control, channel-count preservation, and loop/zero-crossing
  handling all matter from day one.

## Module architecture

Preserve the scaffold's split: pure, REAPER-free logic in one set of files
(unit-tested outside the DAW via the existing `tests/` + CTest harness), REAPER-
facing shells in another.

Pure (no REAPER types, fully unit-tested):
- `bank_model` — the `Sample` metadata struct and the `BankIndex` (add / remove /
  query / tier moves / dedup-by-hash) plus JSON serialize/deserialize to a
  `std::string`. This is the heart; test it hard.
- `peaks` — compute waveform min/max bins from raw PCM. Feed it a known signal
  (sine, ramp) and assert the envelope. We compute our own thumbnails from the
  captured file rather than depending on REAPER's peak API — we own the file
  format, so this is simpler, testable, and dependency-free.

REAPER-facing:
- `capture` — the `ICaptureBackend` interface plus `OfflineRenderBackend` and
  `RealtimeRecordBackend`. Input: a `CaptureRequest` (capture scope — item or
  track, time range, tail, SR/bit-depth/channels, output path). Output: a finished
  file + a populated `Sample` handed to `bank_model`. Capture is always wet; the FX
  *scope* (not a wet/dry dial) is the control — the pure `render_settings` module
  maps a scope to its render-source bits and its FX-bypass plan (see §Capture FX
  scope below).
- `insert` — placement via `InsertMedia`; conform-to-project-tempo vs literal,
  as an explicit flag (never silent stretching).
- `bank_panel` — the docked grid: LICE-drawn thumbnails, audition, multi-select,
  keyboard navigation. Reuses the docking setup already in `mpe_view.cpp`.
- `persist` — project ext state <-> `bank_model` JSON; project-relative path
  resolution (resolve bank folder from the current project path).
- `actions` — registers the capture/placement/slot action family and routes each
  to the modules above (the `command_id` + `gaccel` + `hookcommand` pattern from
  `main.cpp`).

## Data model (sketch — refine in code)

`Sample`: id, display name, relative file path, source mode, source range (start/
end in project time + PPQ), track GUID(s) if applicable, wet/dry, channel count,
sample rate, length (seconds + musical/beats), capture tempo, optional key,
peak/RMS/LUFS, clip flag, tier (scratch | archive), content hash, provenance
(parent sample id + FX-chain snapshot string, when resampled from another sample),
created timestamp.

`BankIndex`: ordered collection of `Sample`, keyed by id; hash lookup for dedup;
tier filtering; JSON round-trip. Scratch tier is auto-prunable; archive is kept.

## REAPER API surface (verify all signatures)

Offline render (the crux — prototype this first):
- Drive render settings with `GetSetProjectInfo` (`RENDER_BOUNDSFLAG`,
  `RENDER_STARTPOS`, `RENDER_ENDPOS`, `RENDER_TAILFLAG`, `RENDER_TAILMS`,
  `RENDER_SRATE`, `RENDER_CHANNELS`, `RENDER_SETTINGS`) and
  `GetSetProjectInfo_String` (`RENDER_FILE`, `RENDER_PATTERN`, `RENDER_FORMAT`).
  The `RENDER_SETTINGS` bit choice is driven by capture scope: **item** scope renders
  selected media items (single-file), **track** scope renders selected tracks via
  master. There is **no master-mix scope** (to capture the master, render a track).
  Render is always wet — REAPER has no true pre-FX "dry" render bit; FX scoping is done
  by the FX-bypass-around-render mechanism, not a render bit (see §Capture FX scope).
- Trigger a no-dialog render via the appropriate render action / `RENDER_SETTINGS`
  bit. Confirm the exact command id and the "render without opening dialog" flag
  against current REAPER — do not assume; test that it runs headless.
- Determinism is a hard requirement: two identical requests must produce
  bit-identical files (enables the null test below).

Realtime record (**track scope** — item realtime is deferred):
- Taps the **selected track**, not the master. Recipe: create a hidden temp track,
  route a **post-fader send from each selected source track into it**
  (`CreateTrackSend`, default post-fader/full-stereo — captures each track's own
  output *before* the parent/master sums it, so the tap is chain-independent by
  construction and needs no FX bypass), set the temp's record mode to record-output
  (latency-compensated, `B_MAINSEND=0` so it does not sum back), arm (`I_RECARM`),
  `CSurf_OnRecord`, run for the range, `OnStopButtonEx`, then move the recorded file
  into the bank and delete the temp track (which drops the sends — no source track is
  left mutated). Verify `I_RECMODE` values for output-recording.
- Master→track sends are refused by REAPER as feedback loops — this is *why* the tap
  is the selected track's post-fader output, not the master.

Sources & metadata:
- Time selection: `GetSet_LoopTimeRange`. Razor edits:
  `GetSetMediaTrackInfo_String(track, "P_RAZOREDITS", ...)`. Tempo:
  `Master_GetTempo` / `TimeMap2_timeToBeats` / `GetProjectTimeSignature2`.
  Selected items/tracks: `CountSelectedMediaItems` / `GetSelectedTrack`.

Placement:
- `InsertMedia(path, mode)` at edit cursor / new track / replace selection
  (verify mode bits). `SetEditCurPos`. Wrap edits in `Undo_BeginBlock2` /
  `Undo_EndBlock2`.

Persistence & paths:
- `SetProjExtState` / `GetProjExtState` (namespace e.g. `"reasampler"`) for the
  index JSON. Resolve project folder via `EnumProjects` / `GetProjectPathEx`;
  store the bank under a project-relative subfolder; keep only relative paths in
  the index.

## Build order (each milestone independently testable)

1. `bank_model` + JSON round-trip + unit tests. (pure — no REAPER)
2. `peaks` + unit tests. (pure)
3. Offline capture of the time-selection master mix to a wav in the project bank
   folder; add a `Sample`; log it to the console. (the render-driving spike)
4. `persist`: write the index to proj ext state, reload on project open; confirm
   it survives Save / Save As. (bank travels with the .rpp)
5. `bank_panel`: docked grid with thumbnails, audition, selection.
6. `insert`: "insert selected sample at edit cursor" action via `InsertMedia`.
7. Capture action family: two FX scopes — **item** (item/take FX only) and **track**
   (item FX + the selected track's own track FX), each chain-independent (the
   out-of-scope chain is neutralized to unity for the render). No master scope — to
   capture the master, render a track. Each scope captures over a **time-or-razor
   range** (razor is a range source, not a mode: razor-when-present, else time
   selection). Capture is always wet; the scope is the control. All registered as
   bindable actions (tail off; a tail-on variant is a later opt-in).
8. `RealtimeRecordBackend` behind the same interface.
9. Slots: "capture to slot N" / "insert slot N", MIDI-bindable (MPC-style).
10. Provenance + "re-capture from source"; null-test verify action.
11. Polish: batch capture (per selected item / per razor area), resample-and-mute-
    source, conform-on-insert, drag-out to OS.

## Precision invariants (enforce, test)

- **Null test:** a dry offline capture of a range, re-inserted at its source
  position, nulls to silence against the source. Ship this as a verification
  action; it is the tool's trust anchor. (Verification action cut per
  `docs/product/provenance.md` — manual verification only.)
- **Bit-identical repeats:** identical offline requests produce identical files.
- **Non-destructive:** capture never mutates source items or tracks (realtime's
  temp track is created and removed cleanly; source routing is restored).
- **Exact bounds:** no rounding of the requested range; no added silence unless a
  tail is explicitly requested; channel count preserved (no silent stereo fold).
- **Relative paths only** in the persisted index.

## Non-goals / guardrails

- No auto-insertion of captures into the arrange (see the load-bearing principle).
- Native OS drag-out is deferred to the final milestone — `InsertMedia`-driven
  placement is the primary path and must work first.
- Do not depend on REAPER's peak API for thumbnails; compute from the captured
  file.
- Do not silently time-stretch on insert; conform is opt-in.
- Do not trust this brief's API names blindly — verify against the SDK header.

## Open questions to resolve during build

- Exact no-dialog render command/flag on the current REAPER build.
- ~~Realtime record routing that captures wet master output without altering the
  user's monitoring.~~ **Resolved:** realtime taps the *selected track* (track scope),
  not the master — a post-fader track→temp send is chain-independent by construction,
  so there is no wet-master-routing problem and monitoring is untouched (see the
  Realtime record API surface above).
- Audio format for the bank (wav bit depth default; allow float for wavetable
  fidelity).
- Thumbnail cache: recompute vs store peak bins alongside the index.

---

# Design View — additive phase spec

> **Additive section.** This is a standalone phase parallel to — not part of — the
> M0–M11 capture roadmap above. Nothing above changes. Product framing (workflow
> narrative, screenset differentiation, N-mode reasoning, design-direction calls)
> lives in `docs/product/design-view.md`; this section is the authoritative
> technical spec, matching the house style of the capture spec. Same standing
> discipline applies: **verify every REAPER API name/signature against
> `vendor/reaper-sdk/sdk/reaper_plugin_functions.h` before use.**

## What it is

A **track-visibility-plus-processing "mode" system**, toggled from the ReaSampler
window. Tracks used purely for sound design (scratch oscillators, FX mangling,
resampling sources) are tagged into **Design** mode; the arrangement's real tracks
are **Arrange** mode (the default). Toggling to a mode **hides and disables** the
tracks that don't belong to it. Workflow value: mental separation + clutter
elimination — be in Design view, resample into the bank, flip to Arrange, place it.

Design View is a **mode projection over REAPER's single arrange timeline** —
reaching both **tracks** (parked per mode) and **items** (lane-split per mode on
shared tracks). It approximates two canvases without a second surface: same project,
same timeline, but each stance sees its own tracks and its own items. It **never**
duplicates the project or opens a second window.

> **Two-canvas reach — settled (2026-07-23).** The original single-canvas framing
> here was derived from a REAPER constraint, not chosen as a product stance. Daniel
> reopened it — "as close as possible to two separate design and arrange canvases,
> same project, different items and leaves" — and has now signed off on the
> mechanism and resolved all forks. The "different leaves" half is delivered by
> track-parking (D1). The "different items" half is delivered by **per-item mode
> membership via REAPER fixed lanes** (track-side `I_FREEMODE=2`, `I_NUMFIXEDLANES`,
> `C_LANEPLAYS:N`; item-side `I_FIXEDLANE`, `C_LANEPLAYS`, `B_FIXEDLANE_HIDDEN`):
> each mode owns a lane on a shared track, and toggling shows/plays only the active
> mode's lane. This is an item-visibility projection over the one timeline — the
> exact analog of today's track-visibility projection — with still **no** literal
> second surface, window, or duplicate project. It is an **additive sub-phase (Phase
> D2 / Phase E)** on top of D1, spec'd in *§Two-canvas sub-phase* below. Full framing
> and the rejected alternatives (timebase-offset, subproject) live in
> `docs/product/design-view.md` §Two-canvas direction.

It is the visibility/processing analog of the capture pillar's load-bearing rule:
**designing and arranging are separate stances on one timeline**, and the tool
enforces the separation **without ever destroying the user's real state.**

## Settled decisions

- **Membership.** Default = Arrange; every untagged leaf belongs to it. Leaves
  **opt in** to Design (or any mode). No track appears in two modes at once except
  (a) via an explicit **show-both** toggle, or (b) parent/folder derivation.
- **Parents are derived, never tagged.** A parent/folder track is visible in mode M
  if **either** (a) any descendant leaf is visible in M (descendant-derived), **or**
  (b) the parent belongs to M by its **own membership** — and an untagged parent is
  an Arrange member by default. So an untagged folder carrying its own FX/media
  above all-Design leaves shows in **both** Arrange (its own default) and Design
  (derived from its children). A parent is **never parked** in any mode it is visible
  in. Rule of thumb: **tag leaves; parents follow** — the common case, since a
  content-bearing folder still surfaces wherever its own membership places it.
  Master track is always visible and never touched.
- **N-mode model, two-mode UI.** The data model carries arbitrarily many modes; the
  UI ships **Arrange** + **Design**. A mode is (stable id, display name, ordinal).
  Arrange is special only as the default home for untagged leaves.
- **Parking a track** (inactive-mode leaf): `B_SHOWINTCP=0`, `B_SHOWINMIXER=0`
  (hide both panels), `B_MAINSEND=0` (out of mix), `I_FXEN=0` (FX bypassed), and
  `TrackFX_SetOffline(track, fx, true)` for **each** FX (reclaim CPU). Full CPU-park
  is the deliberate choice over mix-removal-only.
- **Never touches `B_MUTE` / `I_SOLO`.** The tool owns only visibility,
  `B_MAINSEND`, `I_FXEN`, and per-FX offline — on every managed leaf, tagged or
  untagged. User mute/solo survives every toggle untouched.
- **Persistence.** Membership index + last-active mode + per-track flag snapshots
  ride in the existing `"reasampler"` project ext-state namespace and travel with
  the `.rpp`. On project open, reapply the active mode's visibility + processing.

## Precision invariants (enforce, test)

- **Non-destructive restore.** For every flag the tool drives, snapshot the prior
  value **before** parking; on toggle-back restore **from the snapshot**, never to a
  hardcoded "on." Round-trip (snapshot → park → restore) returns every driven flag
  to its captured value. This is the phase's trust anchor — the analog of the
  capture null test — and is enforced in the pure layer.
- **Mute/solo untouched.** No toggle ever reads or writes `B_MUTE` / `I_SOLO`.
- **Master untouched.** The tool never drives the master track's visibility flags
  (the SDK forbids `B_SHOWINTCP`/`B_SHOWINMIXER` on master; the invariant agrees).
- **GUID-keyed, reorder-safe.** Membership keys on track GUID (`GetTrackGUID`),
  never track index; tolerates unknown/stale GUIDs (prune on reconcile).
- **Relative/portable state only** in the persisted view section (GUID strings, mode
  ids — no absolute paths, no index positions).

## Documented caveat

Offlined FX **re-instantiate** when a track returns to the active mode. Stateful
plugins (convolution, loaded samplers, tail-holding effects) re-initialize on
return — possible load hitch, un-persisted internal state lost. Accepted cost of
the CPU reclaim; surface it at the toggle affordance (tooltip).

## Module architecture (preserve the pure/shell split)

Pure (no REAPER types, unit-tested — the mirror of `bank_model`):
- `view_mode_model` — mode registry (id/name/ordinal; Arrange + Design seeded);
  membership index (`track GUID → { mode ids }` + per-track show-both flag; add /
  remove / retag / query); **folder-tree-aware** visibility derivation (given the
  current parent↔child tree supplied by the shell + the active mode, compute the
  visible set); the **parking/restore planner** (given active mode + snapshot
  record, emit the exact (track, flag, value) operation lists for park and
  restore — where the restore invariant is enforced); JSON round-trip of modes +
  membership + show-both + snapshots + active mode.

REAPER-facing:
- `view` shell — reads `I_FOLDERDEPTH` across the track list to build the
  parent↔child tree and feeds it to `view_mode_model`; applies the planner's operations
  via `SetMediaTrackInfo_Value` (`B_SHOWINTCP` / `B_SHOWINMIXER` / `B_MAINSEND` /
  `I_FXEN`) and `TrackFX_GetCount` + per-FX `TrackFX_SetOffline`; snapshots prior
  flag values before parking; resolves GUIDs via
  `GetTrackGUID` / `guidToString` / `stringToGuid`. Never touches master
  visibility, never touches `B_MUTE` / `I_SOLO`.
- `persist` (slice) — serialize/deserialize the view section into the
  `"reasampler"` namespace alongside the bank; on project open, rebuild the tree
  and reapply the active mode.
- `actions` (entries) — toggle active mode; activate mode: Arrange / Design;
  tag/untag selected tracks → mode; show-both for selected tracks. Registered with
  the `command_id` / `gaccel` / `hookcommand` pattern; toggle + mode-jumps
  MIDI-bindable.
- UI (in the ReaSampler / bank_panel window) — a **segmented mode switch**
  (`[ Arrange | Design ]`) in the window header, active segment lit; small per-mode
  membership count; the offlined-FX caveat as a tooltip. Tag/untag acts on the
  current REAPER track selection, not a per-track widget.

## Show-both semantics

A **per-track "pin visible across modes"** flag that **re-enables processing**
whenever shown. A show-both leaf appears in every mode's visible set and is **never
parked** — its driven flags stay at snapshot/restored values, FX online, in the
mix. ("Show but keep parked" is not offered — a visible-but-silent-and-offline
track is clutter with a thumbnail.) Stored on the membership record; persists;
togglable per selection.

## REAPER API surface (verify all signatures)

- Visibility/routing/FX flags via `GetMediaTrackInfo_Value` (snapshot) /
  `SetMediaTrackInfo_Value` (apply): `B_SHOWINTCP`, `B_SHOWINMIXER`, `B_MAINSEND`,
  `I_FXEN`. (Note: brief cited `B_SHOWINMCP`; verified SDK name is `B_SHOWINMIXER`.)
- Per-FX offline: `TrackFX_GetCount` + `TrackFX_SetOffline(track, fx, offline)`.
- Folder tree: read `I_FOLDERDEPTH` per track to derive parent↔child structure.
- GUID keying: `GetTrackGUID`, `guidToString`, `stringToGuid`.
- Persistence: `SetProjExtState` / `GetProjExtState` under `"reasampler"` (shared
  with the bank index — one blob, two logical sections).
- Wrap flag mutations in `Undo_BeginBlock2` / `Undo_EndBlock2` as appropriate.

## Non-goals / guardrails

- **No *literal* second canvas.** A literal second arrange surface, a second window,
  or a duplicated project stays **rejected** — reject any such path in review. This
  guardrail was **narrowed (2026-07-23), not lifted**: per-item mode separation via
  REAPER fixed lanes on shared tracks (an item-visibility projection over the one
  timeline) is now **allowed** and specified in *§Two-canvas sub-phase* below. Paths
  that remain **rejected**: subproject / second-project-file approaches, and
  overloading item `D_POSITION` with mode semantics (timebase-offset regions) — the
  latter collides with the capture null test. See `docs/product/design-view.md`
  §Two-canvas direction for why those were rejected.
- **Never touch mute/solo.** Any code path reading/writing `B_MUTE` / `I_SOLO` is a
  bug.
- **Never drive a manual lane** (Phase D2). A mode toggle touches only **managed**
  lanes (minted by the mode system, keyed in the lane-ownership index). Any code path
  that shows, hides, silences, or re-lanes a **manual** lane — a user-created comp/take
  lane outside the mode system — is a bug. The tool drives only lanes it created.
- **Every leaf is managed.** The mode system owns all leaf tracks: an untagged leaf
  is an Arrange member, and when the active mode is not Arrange it is fully parked
  and snapshot-restored exactly like a tagged leaf out of its mode. **show-both** is
  the only way to opt a leaf out of parking. (Parents stay visibility-only, master
  is never touched — see below.)
- **Restore from snapshot, never to a default.** No hardcoded "on" restores.
- **Verify API names** against the SDK header before use.

## Open questions to resolve during build

- Reconcile residuals (bulk behavior shipped — `ViewModeModel::reconcile(liveGuids)`
  prunes orphaned snapshots on every toggle/load; folder restructure is self-healing
  because the tree is rebuilt each toggle; membership is intentionally kept so
  undo-delete preserves the tag). Two sub-items remain deferred:
  - **FX-GUID keying for `restoreFxOffline`:** currently restores by slot index; a
    reshuffled FX chain while parked will restore to the wrong slot. Fix requires
    FX-GUID keying + snapshot-schema migration — deferred.
  - **Dormant membership entries:** truly-deleted tracks accumulate stale entries in
    persisted `view_state` (harmless and bounded); natural home is a future
    user-initiated "compact" action, not automatic pruning (which would reintroduce
    undo-delete tag-loss).
- Interaction with the user having a screenset active (Design View drives the same
  flags a screenset recall would; last writer wins — confirm no surprising fight).

## Two-canvas sub-phase (Phase D2 / Phase E) — additive to D1

> **Additive sub-phase, settled 2026-07-23.** Extends D1's track-level mode
> projection to **item level** so each stance owns its own items as well as its own
> tracks. Nothing in D1 changes; this wraps it. Runtime floor rises to **REAPER 7**
> for this sub-phase (fixed lanes shipped in v7). Product framing in
> `docs/product/design-view.md` §Two-canvas direction.

### What it adds

On a track present in **both** stances (a show-both track, or a folder carrying its
own media), each mode owns a **fixed lane**: the active mode's lane shows and plays;
the inactive mode's lane is hidden and silent. A Design take and an Arrange take can
then live on the *same track, same time position*, without colliding on the view.
Track-only-in-one-mode content is still handled by D1 track-parking, unchanged.

### Settled decisions

- **Mechanism: fixed item lanes.** Map mode → lane; toggle drives per-lane play/show
  so only the active mode's lane is present. Items keep their real position and real
  track — nothing is moved in time or deleted. SDK surface (**verified present in
  `vendor/reaper-sdk`**): track-side `I_FREEMODE = 2`, `I_NUMFIXEDLANES`,
  `C_LANEPLAYS:N`; item-side `I_FIXEDLANE`, `C_LANEPLAYS`, `B_FIXEDLANE_HIDDEN`.
  **`I_FREEMODE` changes require `UpdateTimeline()`** to take visible effect.
- **Membership: adoption rule for new items; active mode for new tracks.** New
  **tracks** are tagged to the active mode at creation. New **items** follow an
  adoption rule: if the item's track has pre-existing managed-eligible content
  spanning exactly **one** mode, the item adopts **that mode** — the track stays
  single-mode, no lane split, nothing stranded. The active-mode fallback applies
  only when the track is empty (no prior content) or already spans multiple modes.
  Items in **manual lanes** are excluded from the prior-mode computation and are
  not auto-tagged at all (manual/managed boundary unchanged). Deliberate multi-mode
  splits occur only via the explicit tag/move-item actions, never via auto-tag.
  Pre-existing content defaults to **Arrange**. Membership is **exclusive per
  item**: an item lives in exactly one mode, except via the existing **show-both**
  escape hatch. (New-track tagging follows the same active-mode rule as D1 track
  membership; the adoption rule is a refinement for items on shared tracks.)
- **Inactive-mode content is hidden AND silenced.** The off-mode lane is set
  `C_LANEPLAYS = 0` — neither shown nor played — consistent with exclusive
  membership and with D1's "flipping modes is a real change, not cosmetic." Show-both
  is the deliberate opt-out for a lane that must stay audible across modes.
- **Managed vs. manual lanes — indexed and distinct.** Fixed lanes are also REAPER's
  native comping surface: a user may keep **their own** manual lanes (comp takes,
  alternate reads) on a track alongside the mode system's lanes. The tool maintains a
  **lane-ownership index** — per (track GUID, lane): **managed** (which mode owns it)
  vs **manual** (user-minted, outside the mode system). Mode operations touch **only
  managed lanes**; manual lanes are never shown, hidden, silenced, or re-laned by a
  toggle, and their `C_LANEPLAYS` stays exactly as the user set it. Items a user adds
  to a **manual** lane are **not** auto-tagged (auto-tag governs normal timeline
  content, not hand-managed lanes). The ownership index rides in `"reasampler"`
  `view_state` alongside the membership index, GUID-keyed and portable.
- **Capture placement is mode-aware.** An **explicit** placement while in Design mode
  — including capture-and-place — lands the item in the **Design lane**; the same
  rule governs manual insertion. **The capture load-bearing principle is untouched:**
  capture still writes a file + index entry and **never** auto-inserts; this governs
  only *where an explicit placement lands*.
- **REAPER floor: v7.** No version-gate branch — below v7 this sub-phase is simply
  unavailable.

### Precision invariants (unaffected — called out explicitly)

The capture precision invariants — **null test, bit-identical repeats,
non-destructive, exact bounds, relative-paths-only** — are **entirely unaffected** by
this sub-phase. No capture path changes; lanes are a placement/view concern
downstream of the written file. Lane assignment and `C_LANEPLAYS` are **reversible
flags**: the item is never relocated in time or deleted, so the sub-phase extends the
D1 non-destructive snapshot/restore contract to a new (item-lane) flag family rather
than introducing any destructive operation.

**New invariant — mode operations touch only managed lanes.** A mode toggle drives
only lanes the mode system minted (managed lanes, keyed in the lane-ownership index).
Manual lanes — user-created comp/take lanes outside the mode system — are **never**
shown, hidden, silenced, or re-laned by a toggle; their `C_LANEPLAYS` is left exactly
as the user set it. This is the fixed-lane analog of *never touch `B_MUTE`/`I_SOLO`*
and *never touch master*: **the tool drives only what it created.** Enforceable and
testable in the pure layer — the "which lanes may this toggle touch" decision is a
pure query over the ownership index; only reading REAPER's live lane state is shell.

### Module architecture (preserve the pure/shell split)

- **Pure (`view_mode_model` extension).** Lane math — which lane maps to which mode,
  which `C_LANEPLAYS` value per mode, the item-lane op family alongside the existing
  track-flag op family — is **REAPER-free and unit-tested**, mirroring the D1
  planner. The **lane-ownership index** (per (track GUID, lane): managed-which-mode vs
  manual) and the **"which lanes may this toggle touch" query** (managed only) are
  pure and unit-tested — the planner emits lane ops for managed lanes only and never
  for manual lanes. The **auto-tag decision** is pure too: given *a set of new
  track GUIDs + active mode* (active-mode rule) and *a set of new items, each
  carrying the set of modes already present on its track* (`NewItem::trackModes`)
  — plus the manual-lane exemption flag — `autoTagNewContent` produces the
  membership writes. The adoption guard runs inside the pure layer: single prior
  mode → adopt it; empty / multi-mode track, or new track → fall back to active mode.
- **Shell.** Two shell responsibilities. (1) **Apply** the planner's item-lane ops
  (`I_FREEMODE`/`I_FIXEDLANE`/`C_LANEPLAYS`/`B_FIXEDLANE_HIDDEN` via the media-item
  info setters, `UpdateTimeline()` after `I_FREEMODE` changes) — for managed lanes
  only. (2) **Detect** new content and read live lane state — see below.
- **Persistence.** The tool persists which lane maps to which mode **and the
  managed/manual ownership index** (a small addition to the `"reasampler"` view
  section); REAPER stores fixed lanes and lane-plays in the `.rpp` natively.

### New-content detection (implementation design point)

REAPER exposes **no clean "item added" / "track added" event callback.** Auto-tagging
therefore requires the shell to **diff project state on the panel's existing timer** —
the `bank_panel` already polls and fingerprints the bank; this extends that machinery
to the timeline's tracks and items.

- Each poll, compare the live track/item GUID set against the previous poll's set.
  New **track** GUIDs are tagged to the then-active mode. New **item** GUIDs are
  passed to `autoTagNewContent` with the pre-existing mode-span of their track
  (`NewItem::trackModes`): single-mode track → adopt that mode; empty / multi-mode
  track → fall back to the then-active mode. Manual-lane items are excluded.
- Correctness the implementation must handle: the **first poll after project open must
  not mass-tag** pre-existing content (pre-existing defaults to Arrange, per the
  membership rule).
- **Manual-lane exemption (design point).** An item added to a **manual** lane is
  **not** auto-tagged — auto-tag governs normal timeline content, not lanes the user
  hand-manages. Distinguishing the two may need a heuristic at detection (e.g., an item
  whose `I_FIXEDLANE` is marked manual in the ownership index is exempt; content
  outside any managed lane on a mode-managed track is subject to the adoption rule). The
  precise rule is an **open implementation design point**; the settled boundary is that
  manual-lane content is off-limits to auto-tag.
- **Lane-identity fragility (design point).** `I_FIXEDLANE` is the lane's identity and
  is how the ownership index keys to a lane. Whether the index survives lane
  reorder/renumber/deletion without going stale is an **implementation design point**
  (same class as GUID-keyed reorder-safety for tracks) — flag, don't solve here.
- Pure/shell seam: the **detection** (diffing REAPER's live set each tick, reading live
  lane ownership and per-item `trackModes`) is shell; the **tagging decision** and the
  **managed/manual lane query** (new track GUIDs + active mode; new item GUIDs +
  `trackModes` + active mode + manual-lane exemption ⇒ membership + lane writes) are
  pure and unit-tested.

### REAPER API surface (verify all signatures)

- Fixed lanes — track: `I_FREEMODE` (=2), `I_NUMFIXEDLANES`, `C_LANEPLAYS:N` via
  `GetMediaTrackInfo_Value`/`SetMediaTrackInfo_Value`; item: `I_FIXEDLANE`,
  `C_LANEPLAYS`, `B_FIXEDLANE_HIDDEN` via `GetMediaItemInfo_Value` /
  `SetMediaItemInfo_Value`. Call `UpdateTimeline()` after `I_FREEMODE` changes.
- Detection reuses the `bank_panel` timer + GUID fingerprinting already in place;
  item GUIDs via the item's `GUID` (`GetSetMediaItemInfo_String` `"GUID"`), track
  GUIDs via `GetTrackGUID` as in D1.
- **Verify every name/signature against the SDK header before use** — the surface is
  verified *present*, but confirm argument order, types, and flag values.

---

# Multi-bank — additive phase spec

> **Additive section.** This is a standalone phase parallel to — not part of — the
> M0–M11 capture roadmap and Phase D above. Nothing above changes. Product framing
> (workflow narrative, pool-privilege reasoning, movement semantics, UI-direction
> calls) lives in `docs/product/multi-bank.md`; this section is the authoritative
> technical spec, matching the house style of the capture and Design View specs.
> Same standing discipline applies: **verify every REAPER API name/signature
> against `vendor/reaper-sdk/sdk/reaper_plugin_functions.h` before use.**

## What it is

The single per-project **bank** (`bank_model` / `BankIndex`) is generalized into a
**multi-bank system**. The existing bank becomes **the pool** — a default,
always-present bank that every capture lands in unless another bank is the active
target. On top of the pool the user creates **named banks** ("Drums", "1-Shots",
"Synth Hits") that group samples for a purpose. Samples move freely between any
banks, including to and from the pool. Exactly one bank is the **active bank** — the
capture target — the pool by default.

**This is the container generalization of the capture pillar's bank.** The pool is
to banks what Arrange is to modes: structurally one member of an N-collection, but
privileged as the default home. The capture pillar's load-bearing rule is
untouched — capture still writes a file + an index entry and never inserts into the
arrange; the only change is *which* index the entry lands in.

## Settled decisions

- **The pool is privileged, not special-cased.** Structurally the pool is one
  `BankIndex` among many in the container (mirror of "Arrange is just another
  mode"). Semantically it is privileged: it **always exists**, is **un-deletable**,
  and is **un-renamable** (fixed id + fixed display name "Pool"). New projects and
  migrated single-bank projects start with the pool and **zero** named banks. This
  keeps the data model uniform (no pool-shaped special type) while the rules layer
  enforces the three privileges.
- **Container in the pure core; a `BankIndex` per bank.** A new pure module
  `bank_book` owns an ordered registry of banks, each bank = `{ stable bank id,
  display name, ordinal, BankIndex }`. **`BankIndex` is untouched** — the multi-bank
  layer wraps it, it does not modify it (additive; no `bank-id` field on `Sample`).
  Bank id is the stable key (GUID-style, minted on bank create); display name and
  ordinal are mutable (rename / reorder). **Display names are unique**, enforced in the
  pure model on create and rename: `createBank` / `renameBank` reject a name that
  duplicates an existing bank's (renaming a bank to its own current name is a no-op
  success). The comparison is **trimmed + case-insensitive (ASCII)**, so "Drums",
  "drums", and " Drums " cannot coexist; the pool's reserved name "Pool" is protected
  by the same check. Uniqueness makes by-name resolution in the action shell
  unambiguous by construction. The pool is the first, seeded, fixed-id
  member. `bank_book` is the mirror of `bank_model` and `view_mode_model`: pure, no
  REAPER types, unit-tested outside the DAW, JSON round-trip.
- **Active bank lives in the model, routes through the capture path.** `bank_book`
  carries the active bank id (defaults to the pool). The capture action family
  resolves "which bank does this capture land in?" by asking the session for the
  active bank's `BankIndex`, then adds exactly as today. No capture backend changes;
  only the add-target is selected upstream. Activating a bank is a model mutation +
  a persist write; it never touches the timeline.
- **Movement moves the index entry, not the file.** Moving a sample from
  bank A to bank B is an **index-only** operation: remove the `Sample` from A's
  `BankIndex`, add it to B's. The underlying file stays in the project bank folder —
  banks are logical groupings over one shared file pool, not separate folders on
  disk. This keeps movement cheap, non-destructive, and immune to path-rewrite bugs.
  (Per-bank subfolders on disk are an explicit non-goal — see guardrails.)
- **Dedup-by-hash is per-bank.** Each `BankIndex` dedups within itself, unchanged.
  Moving a sample whose hash already exists in the destination bank **collapses**
  onto the existing entry there (the move is a no-op add on the destination side,
  and the source entry is still removed) — the same collapse semantics
  `BankIndex::add` already has, now observed across a move. Cross-bank dedup is
  **not** enforced: the same hash may exist in the pool and in a named bank
  simultaneously (that is the point — copy lets a sample be grouped into "Drums"
  while still living in the pool).
- **Move vs. copy are distinct acts; move is the default.** *Move* removes from
  source, adds to destination (one logical sample, regrouped) — it is the **primary,
  low-friction gesture**, so a sample lives in exactly one bank at a time. *Copy* adds
  to destination and leaves the source entry intact (same file, two index entries, two
  banks) — the **deliberate secondary act** for the "in two places at once" case. Both
  are index-only; both share the destination-collapse rule. Under move-as-default the
  pool is the default home and staging ground, not a permanent superset: moving a
  sample into a named bank takes it out of the pool. (See product notes for the
  mental-model reconciliation.)
- **Delete drops members; evacuate returns them.** Deleting a **named** bank drops
  its member index entries (files are **not** deleted — file lifecycle stays owned by
  the capture/prune path). A separate **evacuate** operation moves all of a bank's
  members back to the pool (index-only, same destination-collapse-by-hash as move),
  leaving the bank empty. Intended workflow: *evacuate then delete* to keep the
  samples, *plain delete* to drop the grouping and its members. A plain delete of a
  non-empty bank orphans those members out of every index — their files persist on
  disk until prune, referenced by no bank — so the UI **confirms on non-empty delete**
  and offers evacuate as the alternative. Evacuate cannot be applied to the pool.
- **Persistence: a new ext-state key; the pool folds in and the legacy key is
  retired.** The multi-bank state serializes to a new key `banks` in the existing
  `"reasampler"` namespace, alongside `view_state` and `project_guid`. The pool's
  index rides *inside* the `banks` blob as bank-zero — persisted identically to any
  named bank (one blob, one section, one JSON shape). **Migration:** on load, if a
  `banks` key is absent but a legacy `bank_index` key is present, the legacy index is
  promoted into the pool inside a freshly-minted `banks` blob and the book is
  `{ pool }` with zero named banks — a one-way, lossless promotion. After migration
  the `banks` blob is **authoritative**; the legacy `bank_index` key is **retired** (not
  written or read back going forward). The one-way retirement trades pre-multi-bank
  backward-read compatibility for the clean single-blob shape — an accepted,
  forward-only migration consistent with how M4 project state already moves forward.
- **Vertical-split UI, pool on top.** The bank window splits vertically: **pool on
  top**, the **named-banks region below** (a tab-page strip, one tab per named bank,
  empty when none exist). Two full-height toggles collapse the split: **pool
  full-height** (hide the named-banks region) and **banks full-height** (hide the
  pool). The Design View segmented mode switch already in the window header is
  **orthogonal** and stays — it governs timeline visibility, not bank grouping; the
  two coexist in the header/body without interaction.

## Precision / invariant implications

- **Relative-paths-only survives unchanged.** Every `BankIndex` in the book keeps
  the relative-path invariant at its `add` boundary — the book adds no new path
  handling, because movement is index-only and files never relocate. The invariant
  is enforced N times (once per bank) by the exact code that enforces it today.
- **Non-destructive.** Bank create / rename / delete / activate / evacuate and sample
  move / copy mutate only index + ext-state; no file is written, moved, or deleted,
  and no timeline item is touched. Deleting a **named** bank drops its member index
  entries but does **not** delete their files; file lifecycle stays owned by the
  capture/prune path, not the bank container. A file referenced *only* by the deleted
  bank becomes an orphan on disk — present but indexed by no bank — until the
  capture/prune path reclaims it. That orphaned-until-prune window is designed, not
  accidental; the *evacuate* verb and the confirm-on-non-empty-delete guardrail exist
  to keep the user out of it unintentionally.
- **Travels-with-the-.rpp preserved.** The `banks` blob rides the same ext-state
  namespace and the same GUID-primary project-identity / Save-As-relocation
  machinery as the bank index does today (M4). One shared physical bank folder, one
  ext-state namespace, now three logical sections (banks + view + identity).
- **Determinism / null-test / bit-identical are untouched** — they are properties
  of the capture path and the file, and the multi-bank layer sits above the file
  entirely.

## Module architecture (preserve the pure/shell split)

Pure (no REAPER types, unit-tested — the mirror of `bank_model` / `view_mode_model`):
- `bank_book` — ordered bank registry (`{ bank id, display name, ordinal,
  BankIndex }`); pool seeded with fixed id + name; create / rename / reorder /
  delete named banks (pool-privilege rules enforced here: reject delete/rename of
  pool; delete drops member index entries; **display names unique** — create/rename
  reject a name that duplicates another bank's, trimmed + case-insensitive, "Pool"
  protected); **evacuate** a bank (move every member to
  the pool, index-only, destination-collapse observed; pool cannot be evacuated);
  active-bank id (get/set, defaults to pool); **move** and **copy** a sample between
  banks (index-only, destination-collapse observed); query a bank's index; JSON
  round-trip of the whole book (pool-as-bank-zero inside the blob + named banks +
  per-bank indices + active id + ordinals) and legacy-`bank_index`→pool migration on
  parse (one-way; blob authoritative thereafter).

REAPER-facing:
- `persist` (slice) — serialize/deserialize the book under the `banks` key in
  `"reasampler"` (pool-as-bank-zero inside the blob; no separate `bank_index` key
  going forward); migrate a legacy `bank_index` key into the pool on first load
  (one-way; blob authoritative thereafter, legacy key retired); reload-on-open and
  Save-As survival ride the existing M4 machinery. The session exposes the book the
  way it exposes the bank today; the active bank's `BankIndex` is what the capture
  layer adds to.
- `bank_panel` (extension) — the vertical split: pool grid on top, named-banks
  tab-page region below; two full-height toggles; the active-bank indicator; the
  create / rename / delete / activate affordances. The named-banks tab strip is
  **LICE-drawn** to match the M5 grid and the Design View segmented switch (not a
  SWELL-native tab control), with an **overflow/scroll affordance** so it scales past
  the ~8–12-tab point. Sample move ships **both ways**: a "move to bank" menu on the
  current selection (the bindable front-end for the B3 move action) and
  drag-between-regions (the direct-manipulation accelerator); copy is the deliberate
  secondary act, offered on the menu. Drag carries clear drop-target highlighting on
  the destination region/tab, and a mis-drop is recoverable by design (move is
  index-only and reversible — the user moves the sample back). Reuses the existing
  LICE grid render loop per bank region.
- `actions` (entries) — create bank / rename bank / delete bank (confirm on
  non-empty delete); evacuate bank → pool; activate bank (direct + cycle); move
  selected samples → bank; copy selected samples → bank; pool/banks full-height
  toggles. Registered with the `command_id` / `gaccel` / `hookcommand` pattern;
  bank-activate + move/copy + evacuate MIDI-bindable to suit the capture-heavy
  workflow.

## REAPER API surface (verify all signatures)

No new REAPER API is invented at spec stage — the multi-bank layer is pure model +
persistence + panel UI over machinery M0–M6 already established. Shells will need to
verify against the SDK header where they extend existing surfaces:
- **Persistence:** `SetProjExtState` / `GetProjExtState` under `"reasampler"`, new
  key `banks` (shared blob machinery from M4 — no new API, new key only).
- **Panel UI:** the docked-window + LICE-grid surface from M5 (`bank_panel`),
  extended to two grid regions + a **LICE-drawn** tab strip (with overflow/scroll) +
  toggles. The tab strip, the toggle affordances, and the drag hit-testing are
  custom-drawn on the M5 LICE surface (not SWELL-native tabs); the "move to bank" menu
  uses a SWELL popup-menu surface. **Verify LICE drawing and any SWELL menu/drag
  hit-test usage against the M5 reference / SWELL headers**, and confirm the drag
  hit-test does not collide with the M5 grid's multi-select drag. No new REAPER audio
  API involved.
- **Actions:** the `command_id` / `gaccel` / `hookcommand` contract from `main.cpp`
  (unchanged), new command-id strings under the sampler family prefix.

## Non-goals / guardrails

- **No per-bank folders on disk.** Banks are logical groupings over one shared
  project bank folder. Do not create a subfolder per bank or move files on
  bank-move — reject any such path in review (it reintroduces the path-rewrite
  bug class M4 closed).
- **No cross-bank dedup enforcement.** The same hash may exist in multiple banks
  (that is what copy is for). Do not add a global dedup that collapses across banks.
- **Pool privileges are inviolable.** No action path may delete or rename the pool,
  leave a project with zero banks, or **evacuate** the pool (the pool is evacuation's
  destination, not a source). Enforce in the pure rules layer, not just the UI.
- **Delete drops members; files are never deleted by a bank op.** Deleting a named
  bank removes its member index entries only. No bank operation writes, moves, or
  deletes a file — file lifecycle stays with capture/prune. The UI **confirms on
  non-empty delete** and offers evacuate; do not silently orphan members.
- **Capture still never inserts into the arrange.** The load-bearing principle is
  unchanged; multi-bank only redirects which index the capture lands in.
- **Additive only.** Do not alter `BankIndex`, the M0–M11 capture roadmap, or Phase
  D semantics. `bank_book` wraps; it does not modify.
- **Verify API names** against the SDK header before use.

## Open questions to resolve during build

Forks 1–5 are all settled (see product notes → *Settled forks* and *Fork 5 — settled*,
and the settled-decision prose above). One panel-polish detail remains open.

- **Fork 5 — tab rendering + move affordance (B4). Settled (2026-07-23).** (5a) The
  named-banks region is **LICE-drawn** to match the M5 grid and the Design View
  segmented switch — not a SWELL-native tab control — with an **overflow/scroll
  affordance in scope from the start** so the strip scales past the ~8–12-tab
  breakdown. (5b) Move ships as **both** a "move to bank" menu (the precise,
  MIDI-bindable front-end for the B3 move action) **and** drag-between-regions (the
  direct-manipulation accelerator); copy stays the deliberate secondary act via the
  menu. Drag mis-drop is mitigated by drop-target highlighting and is recoverable by
  design (move is index-only and reversible). Folded into the `bank_panel` prose and
  the API surface below. **Verify LICE tab drawing and any SWELL menu/drag hit-test
  surface against the M5 reference / SWELL headers before use** (confirm no collision
  with the M5 grid's multi-select drag). Analysis in product notes → *Fork 5*.
- **Active-bank indicator placement (B4 polish)** — per-region headers vs. a single
  header readout vs. lit-tab treatment. The "visually unmistakable" requirement is
  settled (fork 4); only the placement is open. Panel-polish detail.

---

# Sample removal — additive spec (Phase B, point B5)

> **Additive section, part of the Multi-bank pillar.** The sample-level companion
> to move/copy/evacuate/delete-bank: a verb that **drops a `Sample`'s index entry**
> from a bank (or the pool). Index-only, non-destructive to the file — it sits on
> the same side of the index/file line as every other Phase B op. Product framing:
> `docs/product/removal-and-prune.md` §Sample-remove. Same verify discipline:
> **verify every REAPER API name/signature against the SDK header before use.**

## What it is

Move, copy, and evacuate all keep a sample *somewhere*; there was no verb to drop
a sample outright. **Sample-remove** is that verb: it removes one `Sample` entry
from one `BankIndex`. It exposes the `remove` primitive `bank_model`'s `BankIndex`
**already has** — B5 wires it to an action + a panel affordance, it does not add a
model capability.

## Settled decisions (spec-level)

- **Remove is index-only.** It removes the `Sample` from a `BankIndex` and mutates
  only index + ext-state. No file is written, moved, or deleted; no timeline item
  is touched. Identical non-destructive posture to move/copy/evacuate/delete-bank.
- **Remove can orphan a file — the same designed orphaned-until-prune state a
  non-empty delete-bank produces.** When remove drops the *last* index reference to
  a file (no other bank holds its hash), that file becomes an orphan on disk,
  referenced by no bank, reclaimed later by **prune** (Phase R) — never by remove.
  This is not a new hazard class; it is the existing "files persist until prune"
  window, reached by a sample-level verb instead of a bank-level one.
- **Collapse-by-hash is unaffected.** Remove targets a specific entry in a specific
  bank. Because cross-bank dedup is deliberately not enforced, removing a sample
  from one bank leaves any same-hash entry in another bank intact — the same
  coexistence copy relies on.
- **The pool's *contents* are removable; the pool *container* is not.** Pool
  privileges (un-deletable, un-renamable, un-evacuable) govern the pool as a
  container. Individual samples **can** be removed from the pool — otherwise the
  pool would be a one-way trap. Remove-from-pool is the pool's own "drop this
  sample" verb and is allowed.
- **Remove scope (fork R-A, SETTLED 2026-07-24 — this-bank).** Remove drops the
  entry from *this* bank only, leaving copies in other banks untouched — the core
  and only shipped verb. The action carries a `scope: this-bank | all-banks` seam,
  but **this-bank is the settled default and the only surfaced affordance**;
  all-banks stays a latent parameter (promotable later behind the seam without a
  rewrite), never a surfaced verb now. See product notes §Fork R-A.

## Precision / invariant implications

- **Non-destructive** extends to remove verbatim: index + ext-state only, no file
  touched, no timeline item touched.
- **Relative-paths-only** is unaffected — remove deletes an entry, it adds no path
  handling.
- **Determinism / bit-identical / null-test (capture)** untouched — remove sits
  above the file, same as all of multi-bank.

## Guardrails

- **Removes are silent — no confirm dialog.** Recoverability is provided by the
  batched REAPER undo (R-B): one Ctrl-Z restores the index entry, whether or not
  the sample was a last reference. Files are never deleted by remove
  (orphaned-until-prune is unchanged). `hashReferencedElsewhere` is a tested
  model API retained for Phase R prune; it has no shell caller in the remove path.
- **Undo (fork R-B, SETTLED 2026-07-24 — batched REAPER undo points, Phase-B-wide).**
  Bank/index mutations integrate into REAPER's undo system as **batched undo points**
  (`Undo_BeginBlock` / `Undo_EndBlock`): the related index mutations of one bank
  operation are batched into a single undo point, so one bank operation is one
  Ctrl-Z. This is a **Phase-B-wide** decision — it applies to
  create/rename/reorder/delete-bank, move, copy, evacuate, *and* remove, retro-
  touching B1–B4, not just B5. **Must-verify before build:** confirm against
  `vendor/reaper-sdk` that `"reasampler"` ext-state mutations participate correctly
  in `Undo_BeginBlock`/`Undo_EndBlock` undo blocks — the whole approach depends on
  it. Surfaced with remove because remove is the first verb whose *only* effect is
  index-entry destruction with no relocation, so it is where the gap first bit; the
  fix is shared. See product notes §Fork R-B.

## Module architecture (preserve the pure/shell split)

- `bank_book` / `BankIndex` (pure) — expose remove of a `Sample` from a bank's
  index (the existing `BankIndex::remove` primitive, surfaced through the book);
  pool contents removable, pool-container privileges unchanged.
- `actions` (entry) — "remove selected sample(s) from bank" (and, under fork R-A,
  a scope parameter); registered with the `command_id`/`gaccel`/`hookcommand`
  contract; MIDI-bindable to suit the capture-heavy workflow.
- `bank_panel` (affordance) — remove on the current selection (menu entry / key),
  reusing the M5 selection model exactly as move/copy do; confirm-on-last-reference
  at this layer.

## REAPER API surface

No new REAPER API. Pure model + a new action command-id string under the sampler
family prefix + a panel affordance on the existing M5 LICE surface. Verify the
command-id/gaccel/hookcommand usage against `main.cpp` (unchanged contract).

## Settled forks (Daniel, 2026-07-24)

- **Fork R-A — remove scope.** Settled: **this-bank** (this-bank-primary, all-banks
  a latent seam-only parameter). Folded into Settled decisions above.
- **Fork R-B — undo model for index mutations.** Settled: **batched REAPER undo
  points** (`Undo_BeginBlock`/`Undo_EndBlock`), Phase-B-wide (retro-touches B1–B4),
  with the ext-state-participation SDK check as a must-verify-before-build. Folded
  into Guardrails above and the Phase B / B1 plan points.

---

# Prune — file-lifecycle spec (Phase R — Reclaim)

> **New pillar, its own lettered phase.** Prune is the file-lifecycle path the
> capture and multi-bank specs forward-reference throughout ("files persist on disk
> until prune", "the capture/prune path reclaims it") but that had no phase, module,
> or point until now. It is the **only** operation in ReaSampler that deletes bytes
> off disk. Namespaced **`R` (Reclaim)** alongside `M` (capture), `D` (Design View),
> `B` (Banks) — it is a distinct pillar, not a Multi-bank sub-step, because it
> serves *every* orphan-producing path (delete-bank, sample-remove, re-capture) and
> carries a new risk class (file deletion) with its own invariants. Product framing
> and the phase-placement justification: `docs/product/removal-and-prune.md` §Prune.
> Same discipline: **verify every REAPER/SWELL/filesystem API name/signature against
> the SDK/SWELL headers before use.**

## What it is

Over a project's life, delete-bank and sample-remove (and, potentially, M10
re-capture superseding an old file) leave `.wav` files on disk that no bank index
references — the "orphaned-until-prune" state the specs design in on purpose.
**Prune is the reclaim pass**: reconcile the physical bank folder against the union
of every bank's index, and reclaim the files nothing references. It makes good on
the promise the rest of the spec keeps making.

## The load-bearing rule

> **Remove creates orphans; prune reclaims them.** Sample-remove and delete-bank
> drop index entries and may leave a file referenced by nothing. Prune is the
> single path that turns such an orphan back into free disk space. **No other
> operation deletes a file; prune deletes *only* files that no index references.**
> A bank op that deletes a file is still a bug — prune is not a bank op, it is the
> file-lifecycle op.

This asymmetry is deliberate and must be stated loudly: every *other* invariant
says "no operation deletes a file." Prune is the sole, explicit exception, and its
entire job is deletion — so it must be the *only* file-deleting authority in the
system, with the strongest guardrails.

## Mirror of `reconcile` — the pure pattern one level down

Prune reuses the shape Design View already shipped. `view_mode_model`'s
`ViewModeModel::reconcile(liveGuids)` reconciles *membership entries* against *live
tracks* and returns the residuals to drop. **Prune reconciles *files on disk*
against *referenced files*** (the union of every bank's index) and returns the
orphan set to delete. Same pure pattern, one level down (files instead of GUIDs).

The **decision is pure and unit-tested**: given the set of files present in the
bank folder and the set of files referenced by the book, compute the orphan set.
Only the two ends touch the shell — *enumerating* the bank folder and *deleting*
the orphans are filesystem I/O. Keep the "which files are orphans" core REAPER-free
and hard-tested (this is the safety-critical part); keep the I/O thin. Same
pure/shell split as `bank_model` / `view_mode_model` / `bank_book`.

## Settled decisions (spec-level)

- **Referenced-set is the union across ALL banks, pool included.** A file is an
  orphan iff **no** bank in the book references it. Because copy lets one file be
  referenced by several banks, prune must union references across the whole book
  before deciding. This is the safety-critical computation — the **prune null
  test** is *prune never deletes a file that any index references.*
- **Project-relative resolution, current folder.** Prune enumerates and deletes
  within the project bank folder using the **same M4 project-relative path
  resolution** the index uses, against the *resolved current* folder — never a
  stale absolute path — so a Save-As relocation cannot cause it to mis-identify or
  mis-target orphans.
- **Dry-run first, always.** Prune reports before it deletes: the orphan count,
  reclaimed size, and (for a small set) the files. The dry-run — compute-and-report,
  the pure core with no deletion — is the primary surface; actual deletion is the
  confirmed second step. A prune that silently sweeps is unacceptable for an
  irreversible file-delete.
- **Scope is the bank system's own leavings, not the folder at large.** Prune
  reclaims files that *were* bank files and are now unreferenced — never a file a
  user hand-dropped into the folder. Prune is a reclaimer of ReaSampler's own
  orphans, not a general folder cleaner.
- **Orphan attribution is an owned-file manifest (fork R-D, SETTLED 2026-07-24).**
  The book tracks the set of files it has created (an **owned-file manifest**);
  prune reclaims `(owned ∩ on-disk) − referenced`. This is the honest encoding of
  "reclaim only our own leavings" and rejects folder-sweep (which would delete
  hand-dropped files). **The seam lands early:** because the manifest is cheap to
  maintain from capture onward but a backfill cliff to reconstruct later, **capture
  writes each file it creates into the owned-file manifest starting in Phase B**,
  even though prune consumes it only in Phase R. R1/R2 consume the manifest; they do
  not build it. The manifest is persisted in the `"reasampler"` ext-state; the exact
  persistence shape (a sibling key vs. folded into the `banks` blob) is a small
  build-time residual, but the manifest-now decision is firm.

## Precision / invariant implications

- **The single intentional exception to "no operation deletes files."** Stated
  above; called out again here so the invariant table is honest: prune is
  destructive-to-files *by design and by exclusive authority*.
- **Relative-paths-only / Save-As machinery reused** — prune resolves paths the
  same way the index does (M4), so it inherits relative-path correctness and
  Save-As survival; it introduces no new path handling.
- **Determinism / bit-identical / null-test (capture)** untouched — prune sits
  below the capture path entirely.
- **Prune null test (new invariant):** a prune of a folder whose every file is
  referenced by some bank deletes nothing; a prune deletes exactly the
  `present − referenced` orphan set and nothing else. Ship as a tested property of
  the pure core.

## Guardrails — the genuinely destructive act

- **Dry-run + confirm-with-manifest** (above): the user approves a *specific*
  deletion (count + size + files), never an abstract "clean up."
- **Never a referenced file; never a non-bank file.** The union-across-all-banks
  rule protects referenced files; the ownership-attribution rule (fork R-D)
  protects hand-dropped files.
- **Safest platform deletion available (fork R-C, SETTLED 2026-07-24 — trash-
  preferred, unlink fallback).** Route deletions to the platform recycle bin / trash
  wherever a portable move-to-trash is available (recoverable outside the app); fall
  back to unlink — behind the dry-run + confirm guardrail — only where the platform
  affords no portable trash. "Delete where possible" means recoverable-trash-
  preferred, never plain unlink-by-default. The move-to-trash surface is an explicit
  per-platform **to-verify** (see REAPER/platform API surface). (R3 verified: Windows
  `SHFileOperationW` + `FOF_ALLOWUNDO` confirmed against SDK 10.0.26100; macOS/Linux
  unlink fallback — no portable SWELL trash surface.)
- **Manual, explicit trigger (fork R-E, SETTLED 2026-07-24 — manual action + panel
  button).** Prune runs via a bindable manual action (dry-run-first, confirm-to-
  delete) **and** a `bank_panel` button that fires that same action — never a silent
  background sweep. The earlier optional "…and prune now at the delete-bank
  confirmation" convenience was **not** selected and is out of scope; a periodic
  background sweep remains rejected (silent irreversible file-deletion violates the
  guardrails).

## Module architecture (preserve the pure/shell split)

Pure (no REAPER types, unit-tested — the mirror of `reconcile`):
- **Prune-reconcile core** — given `{ files present in the bank folder }`,
  `{ files referenced by the book }`, and `{ files the book owns }` (the owned-file
  manifest, R-D), compute the orphan set `(owned ∩ present) − referenced`.
  REAPER-free, filesystem-free, unit-tested hard (the prune null test lives here).
  The referenced-set is unioned across all banks by asking the `bank_book`.

REAPER-facing / filesystem-facing (thin):
- `persist` / session — supplies the referenced-set (union across the book) and the
  owned-file manifest (R-D, written from capture onward in Phase B); resolves the
  current project bank folder via the M4 project-relative machinery.
- A **prune shell** — enumerates the bank folder (filesystem I/O), feeds the
  pure core, presents the dry-run manifest, and on confirmation deletes the orphan
  set (via OS trash where portably available — fork R-C — else unlink). Filesystem
  I/O only; the decision stays in the pure core.
- `actions` (entry) — "Prune bank folder" (dry-run-first, confirm-to-delete),
  registered with the `command_id`/`gaccel`/`hookcommand` contract; **plus a
  `bank_panel` button** (R-E) that fires the same action.

## REAPER / platform API surface (verify all signatures)

No new REAPER *audio* API. New surfaces to verify before use:
- **Filesystem enumeration + delete** — directory listing and file removal for the
  project bank folder. **Verify** the portable approach against SWELL / the existing
  file-handling in `persist` / `capture` (which already resolve and write files);
  prefer reusing whatever path/file machinery M4 established.
- **Move-to-trash (fork R-C, settled trash-preferred)** — verify a portable
  move-to-trash exists (SWELL, or per-platform: Win `IFileOperation`/
  `SHFileOperation`, macOS `NSFileManager trashItemAtURL:`, Linux XDG trash spec).
  This is a **must-verify per platform** before use, not an assumed capability;
  where it is unavailable, fall back to unlink behind the dry-run/confirm guardrail.
  (R3 verified: Windows routes to Recycle Bin via `SHFileOperationW` + `FOF_ALLOWUNDO`,
  verified against SDK 10.0.26100. macOS / Linux: no portable SWELL trash surface
  found — fall back to `unlink` behind the dry-run/confirm guardrail, as specified.)
- **Owned-file manifest persistence (fork R-D, settled)** — a new tracked set in
  the `"reasampler"` ext-state (a sibling key or folded into the `banks` blob —
  build-time residual); shared M4 blob machinery, new data only. **Written from
  capture onward in Phase B** (the seam lands early), consumed by prune in Phase R.
- **Actions** — the `command_id`/`gaccel`/`hookcommand` contract from `main.cpp`
  (unchanged), a new command-id string under the sampler family prefix.

## Non-goals / guardrails

- **Prune is the ONLY file-deletion authority.** No bank op, no capture op, no
  Design View op deletes a file. If any path other than prune deletes a bank file,
  reject it in review.
- **No general folder cleaning.** Prune reclaims the bank system's own unreferenced
  leavings, not arbitrary files a user placed in the folder (fork R-D governs the
  attribution).
- **No silent deletion.** Dry-run + explicit confirm always; no background sweep.
- **No file deleted while any index references it.** The referenced-set union
  across all banks is the safety-critical invariant — enforce and test it in the
  pure core, not just the UI.
- **Additive only.** Prune reads the book and the folder; it does not modify
  `BankIndex`, `bank_book`, the capture roadmap, or Design View semantics.

## Settled forks (Daniel, 2026-07-24)

- **Fork R-C — deletion mechanism.** Settled: **trash-preferred, unlink fallback.**
  Route to the OS trash where a portable move-to-trash is available (recoverable),
  else unlink behind the dry-run/confirm guardrail. Per-platform trash surface is a
  must-verify. Folded into Settled decisions + Guardrails + API surface above.
  Product notes §Fork R-C.
- **Fork R-D — orphan attribution.** Settled: **owned-file manifest**, `(owned ∩
  present) − referenced`; folder-sweep rejected as unsafe. The **seam lands early** —
  capture writes each created file to the manifest starting in Phase B, prune
  consumes it in Phase R. Persistence shape (sibling key vs. `banks` blob) is a
  build-time residual. Folded into Settled decisions + Module architecture + API
  surface above, and added as an up-front Phase B / capture plan point. Product
  notes §Fork R-D.
- **Fork R-E — trigger.** Settled: **manual action + `bank_panel` button**, dry-run-
  first, confirm-to-delete; no background sweep. The delete-time "…and prune now"
  convenience was not selected (out of scope). Folded into Guardrails + Module
  architecture above and the R3 plan points. Product notes §Fork R-E.

**Build-time residual (not a fork):** the owned-file manifest's exact persistence
shape (sibling `"reasampler"` ext-state key vs. folded into the `banks` blob).

---

# MIDI-playback instrument — additive phase spec (Phase S — Sampler)

> **New pillar, its own lettered phase, and — uniquely — its own build artifact.**
> Every prior phase (M / D / B / R / V) ships inside the one `reaper_reasampler`
> extension binary. Phase S does **not**: a REAPER extension *cannot* be a
> MIDI-triggered instrument (it is not a node in any track's signal chain), so the
> instrument is a **second, separate binary** — a native **VST3** plugin the user
> instantiates on an instrument track — that reads ReaSampler's banks and plays them
> MIDI-triggered. Namespaced **`S` (Sampler)** rather than "D" (which would collide
> with Design View). The `M`/`D`/`B`/`R`/`V` extension pillars are untouched. Product
> framing, the plugin-format reasoning, the bare-VST3-vs-JUCE assessment, and the
> settled decision record: `docs/product/midi-playback.md`. Same standing discipline:
> **verify every Steinberg VST3 SDK and REAPER/SWELL API name/signature against the
> vendored headers before use.**

## What it is

A native **VST3 sampler instrument** — a separate product/artifact from the
extension — that maps ReaSampler's captured bank samples across a MIDI keyboard and
plays them back with a real voice engine (polyphony, velocity, envelopes). The
extension stays the sole owner of **capture + organization**; the instrument is the
**playback surface**. The two are *tightly integrated but distinct acts*: the
extension captures and organizes; the instrument plays. Neither crosses into the
other's role — the instrument never captures, the extension never becomes an
instrument.

**Why a VST3 and not the extension (load-bearing, settled — see D1/D5/D6 below).** An
instrument track's "read live MIDI, emit audio per-voice, in REAPER's
routing/record/render path" contract belongs to VST/VST3/CLAP/JSFX plugins, hosted
through an entirely different mechanism than the extension API. The extension SDK's
audio-adjacent surfaces (`Audio_RegHardwareHook`, `kbd_OnMidiEvent`, `PlayPreview`,
`pcmsrc` subclassing) are each the wrong tool for a live-MIDI instrument — the full
reasoning is in `docs/product/midi-playback.md` §1. The instrument is therefore a
standard VST3 plugin; this is not an engineering-around-able limitation, it is what
the plugin *format* is.

## The three locked decisions this spec assumes

Settled by Daniel (2026-07-26); everything below assumes them. Reasoning preserved in
`docs/product/midi-playback.md` §4.

- **D1 — native VST3.** Not JSFX. Full sampler sophistication, clean integration, and
  access to the REAPER VST-host bridge. JSFX retired (cross-platform-for-free is
  worthless under D5, and JSFX gets no 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. REAPER-coupling
  via the bridge is intended. This is the single biggest simplifier — it deletes most of
  what makes VST3 painful.
- **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 VST-host bridge (the integration mechanism, stated once)

A VST3 *hosted inside REAPER* can call back into REAPER's own API by resolving
function pointers by name over the host callback
(`hostcb(&effect, 0xdeadbeef, 0xdeadf00d, 0, "FunctionName", 0.0)` — the same
string-keyed API table the extension uses; verified in `video_processor.h` and the
`reaper_plugin_functions.h` `GetProjExtState`/`SetProjExtState`/`EnumProjExtState`
entries). The plugin can also fetch its **host context** — the track/take/project it
was instantiated in (opcode `0xdeadf00e`). **Consequence:** the instrument reads the
*same* live `"reasampler"` ext-state that `persist` writes, follows the active
project, and needs no "point me at the bank folder" wiring — it asks REAPER which
project it is in. This capability exists *because* the plugin is hosted in REAPER; it
is the technical affordance D6 leaned on. **Must-verify before build:** confirm the
bridge opcodes and the by-name resolution against the vendored
`vendor/reaper-sdk/sdk/` headers (`reaper_plugin.h`, `video_processor.h`,
`reaper_plugin_functions.h`) — the framing doc's opcode citations are verified from
those headers but the exact call marshalling should be confirmed at the spike.

## 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` via the M4 machinery. 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. It sees what `persist` last wrote
  and follows the active project.

## The seam fields — what becomes a bank intrinsic (D-B, settled 2026-07-26)

**The split model (option iii) is the settled answer.** It mirrors the
capture/placement separation:

- **Bank intrinsics (facts about the captured file) live on `Sample`.** **Root note**
  (the MIDI note the sample was recorded at, so it can be repitched across the
  keyboard — distinct from the existing optional *musical key* field) and **loop
  points** (sustain-loop start/end for held notes; sample-accurate, zero-crossing-aware)
  are *facts about the file*, analogous to sample rate, length, and peaks. They are added
  to `Sample` as an **additive field extension** — the same shape as how `provenance`
  was added in M1: new optional fields with JSON round-trip, populated at/after capture,
  defaulting cleanly for pre-existing samples. This keeps the bank a clean,
  tool-agnostic library (WAVs + facts, readable by anything).
- **The performance map (a creative arrangement) lives in the instrument.** **Key
  zones** (low/high note per sample), **velocity layers**, **round-robin groups**,
  **amplitude envelopes** (ADSR), and per-sample tuning/gain trim are a *performance
  choice*, not a fact about a file — they belong to the instrument, not the bank. Under
  the live-state seam the instrument may still *read* performance-map data out of shared
  `"reasampler"` state, so "who owns which field" is a data-ownership decision, not a
  transport one.

**Why the intrinsic fields are added early (D-B, the backfill-cliff reasoning).** The
`Sample` field addition is scheduled as an **early Phase S point** even though the
instrument that consumes them lands later. Rationale (the *design-the-seam-even-if-you-
defer-the-feature* instinct, same as Fork R-D's owned-file manifest): if the fields are
added only when the instrument needs them, every sample captured before then lacks a
root note / loop points and must be backfilled by hand. Adding the fields now — so
capture starts populating them (or at least defaulting them cleanly) — costs almost
nothing and closes the cliff. The field addition touches the **extension** codebase
(`bank_model` + capture + persist), is independently shippable, and lands before the
instrument build leans on it.

## Scope tiers (D-C, settled 2026-07-26 — Tier 0–1 committed, Tier 2 held, Tier 3 optional-forever)

Tiers are minimal → sophisticated; **Tier 0 delivers the core promise** and each tier
above is optional depth, not a prerequisite for the one below.

- **Tier 0 — "the bank plays" (committed).** One sample mapped chromatically across
  the keyboard from its root note; basic polyphony; a simple amp envelope; velocity →
  volume. The honest MVP: point a bank sample at a MIDI track and play it repitched.
  On the native path this is the `SingleComponentEffect` skeleton plus a single-voice
  core, editor deferrable behind a parameters-only default view.
- **Tier 1 — "a keymap" (committed).** Multiple samples zoned across the keyboard (key
  ranges), each with its own root note — a captured *kit* or a *multisampled instrument*
  plays correctly. This is where the root-note + key-range seam fields earn their place.
  One sample per key-region.
- **Tier 2 — "expressive" (HELD — noted, not specified).** Velocity layers, round-robin
  (the anti-machine-gun feature), full ADSR, per-sample tuning/gain trim, sustain loops.
  Where it becomes a tool people reach for. **Explicitly a follow-on** — its points are
  not drawn up in this spec; it is recorded as the next depth increment once Tier 0–1
  proves the instrument belongs in ReaSampler's world.
- **Tier 3 — "instrument polish" (optional-forever).** Filters, filter/pitch envelopes,
  LFOs, per-voice pan, choke groups, a modest FX slot. A direction to leave room for,
  never a commitment. Do **not** let a Tier-3 feature list inflate the build-shape
  decisions.

## The build shape (D-A, settled 2026-07-26 — bare Steinberg VST3 SDK + LICE editor)

**Settled: bare Steinberg VST3 SDK, no JUCE, with the editor drawn in the same
LICE/SWELL stack `bank_panel` already uses.** Reasoning (full assessment in
`docs/product/midi-playback.md` §1a and §4 D-A):

- **The audio-processing scaffolding is bounded.** Using `SingleComponentEffect` (the
  SDK's combined processor+controller base — sanctioned for a non-distributable,
  REAPER-only plugin under D5/D6) plus the SDK's factory macros, a silent-but-loading
  VST3 instrument skeleton is order-of-magnitude a few-hundred lines of
  adapt-from-example ceremony, written once. The AGain / Note Expression Synth SDK
  examples are the copy-source. Not a tar pit.
- **D5 deletes JUCE's biggest justification.** JUCE exists largely for multi-format /
  cross-platform, both of which D5 removed. Its one genuine remaining pull is the editor
  UI — and ReaSampler is the atypical case where even that is weak, because it already
  has a working, docked, custom-drawn LICE UI (`bank_panel`) and a house style. Drawing
  the editor in a VST3 `IPlugView` that hosts a LICE surface reuses that muscle, keeps
  the look house-consistent, and avoids JUCE's AGPL-or-pay license posture (the Steinberg
  SDK is permissive, no revenue gate).
- **The one real edge — the `IPlugView`↔LICE bridge** (window lifecycle, sizing, event
  routing from the host into the draw/hit-test loop) — is *the same class of work*
  ReaSampler already did to dock `bank_panel`, not a new competence, but it is less
  trodden than dropping in a JUCE editor. It is therefore the phase's **opening spike**
  (below), which also converts §1a's experienced-estimates (Windows module-export
  symbol names, factory-macro spellings, exact bridge marshalling) into verified fact
  before the engine build leans on them. VSTGUI (the SDK's bundled toolkit) is the noted
  fallback rung *only if* the LICE bridge proves gnarlier than the panel work suggests;
  JUCE is the last resort behind that.

## The pure core (D3 — the load-bearing split, transplanted)

**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`, tested in the CTest
harness outside any host. This is the heart of the phase; **test it hard.** The VST3
wrapper — the `SingleComponentEffect` subclass, bus setup, the `process` call
marshalling MIDI→core and core→audio-buffer, the `IPlugView` LICE editor, and the bridge
calls that read `"reasampler"` ext-state — is the **thin shell**, the only part that
touches VST3 or REAPER at all. Critically, this split is **invariant under the build-shape
choice**: whether the shell is bare-SDK or (hypothetically) JUCE, the pure core is
identical, REAPER-free, and tested the same way. The format choice is a shell choice; the
core is invariant.

## Module architecture (preserve the pure/shell split — in the new artifact)

Pure (no REAPER types, no VST3 types, unit-tested — the mirror of `bank_model`):
- **Sampler core** — voice allocation/polyphony, amplitude envelope (ADSR), key→sample
  and velocity→sample mapping (the keymap), repitch/interpolation from root note, and
  keymap resolution. REAPER-free *and* VST3-free, unit-tested in CTest against known
  signals (mirror of how `peaks` asserts an envelope). This is D3's pure core and the
  heart of the phase.

Shell (VST3-facing / REAPER-facing, thin):
- **VST3 wrapper** — `SingleComponentEffect` subclass: `initialize` (declare an event
  input bus + an audio output bus, no audio input), `setupProcessing`, `setActive`,
  `setState`/`getState`, and the hot-path `process` that reads MIDI off the event bus,
  drives the pure core, and writes the core's per-voice audio to the output bus. Plus the
  module factory (`GetPluginFactory` + Windows `InitDll`/`ExitDll` — verify exact export
  names at the spike).
- **`IPlugView` LICE editor** — hosts a LICE-drawn surface in the VST3 view seat
  (window creation/sizing, host→draw/hit-test event routing). Reuses the `bank_panel`
  LICE/SWELL competence and house style.
- **Bridge/state reader** — resolves `GetProjExtState`/`EnumProjExtState` by name over
  the host callback, fetches the host project context, reads the live `"reasampler"`
  ext-state (bank index + intrinsic fields + performance map), and resolves WAV audio
  paths the same project-relative way `persist` does.

## Precision / invariant implications

- **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. The bank stays the single
  authoritative artifact (the one-source-multiple-views instinct). Any instrument path
  that writes back into the bank or keeps its own sample copies is a bug.
- **Capture/placement/playback stay distinct acts.** The instrument reads and plays; it
  never captures and never inserts into the arrange. The capture load-bearing principle
  is untouched — Phase S adds a *third* distinct act (playback) without weakening the
  capture↔placement separation.
- **`Sample` field addition is additive and lossless.** Root note + loop points are new
  optional fields with JSON round-trip, defaulting cleanly for samples captured before
  the addition — the same additive, backward-compatible shape as `provenance` (M1). No
  existing `Sample` field changes; no `BankIndex` behavior changes.
- **Relative-paths-only survives.** The instrument resolves audio via the M4
  project-relative machinery; it introduces no absolute paths.

## Embedded TCP/MCP UI (D-D, settled 2026-07-26 — SCHEDULED as a later Phase S point)

A REAPER-hosted VST3 can draw its own UI **inline in the track/mixer control panel**
via `reaper_plugin_fx_embed.h` (the plugin implements `IReaperUIEmbedInterface`; the
same Cockos surface REAPER's own embedded FX use). A ReaSampler instrument can render a
compact keymap/level strip inline in the TCP/MCP, not only in its own window. Because
this uses the **same LICE-class drawing as the D-A editor path**, it composes naturally
with the bare-SDK-plus-LICE build — the groundwork is the groundwork.

**Settled: scheduled, not deferred.** This is a real, in-phase later point — it lands
**after** the main `IPlugView` editor exists (it composes with that LICE path), not a
someday-note. It is polish, not a Tier-0 need, so it sequences last in the phase; but it
is on the roadmap. **Must-verify before build:** the `IReaperUIEmbedInterface` contract
and embed message/lifecycle against `vendor/reaper-sdk/sdk/reaper_plugin_fx_embed.h`.

## Channel mode — mono | stereo (D-E, decided 2026-07-26; PLAN.md S7)

**Decided direction: the instrument gets a per-instance channel-mode toggle — 1 (mono) or
2 (stereo) — that negotiates the REAPER audio bus automatically.** Captures are often
stereo; the current mono downmix is a Tier-0 simplification, not a permanent shape.

- **Mono mode keeps today's path.** The decode-side downmix stands: a stereo source in
  mono mode downmixes (the existing policy), a mono source plays as-is. No engine change
  for mono.
- **Stereo mode is an S3-core extension, not a shell hack (honest).** The S3 core is
  **mono-per-sample by design today** — `SampleData::frames` is one mono stream,
  `Voice::renderFrame` returns a single value, `VoiceEngine::render` writes one channel.
  Stereo mode grows the core a **channel dimension**: 2-channel decoded PCM, per-voice
  **stereo** render (per-channel fractional read + linear interpolation + loop), and a
  per-channel mix in the engine. Mono stays the degenerate (single-channel) case, so
  existing mono behavior is unchanged. This is why S7 sequences first after the editor/embed
  work: it touches the engine Daniel smoke-tests.
- **Where the toggle lives.** Per-instance component state (setState/getState), alongside
  the selected sample — a **performance choice the instrument owns**, never written to the
  bank (D-B: not a file fact). Default preserves current behavior (mono).
- **Cross-mode policy (settled).** Mono source + stereo mode → **dual-mono** (same signal
  both channels, centered). Stereo source + mono mode → **downmix** (the existing
  decode-side policy). The bank's per-sample channel-count intrinsic (already on `Sample`)
  tells the shell how many channels to decode into `SampleData`.
- **Bus negotiation (the "works with the REAPER bus automatically" requirement).** The VST3
  implements `setBusArrangements` so the output bus reports mono or stereo per the
  instance's channel mode, and REAPER's routing follows without manual channel wiring.
  **Must-verify before build:** the `setBusArrangements` / `getBusArrangement` contract and
  REAPER's mono/stereo instrument-bus expectations against the vendored Steinberg SDK +
  `reaper_vst3_interfaces.h`.

## Sampling modes — Trigger vs Gate + pitch envelope (S15/S16; core + editor)

**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."* The feature set is **settled**; two
forks (S15-F1 choke, S15-F2 param granularity) are flagged with leans below.

**Daniel's S16 correction (2026-07-26, verbatim):** *"isn't that ratio stuff going to change
the playback rate? I want duration-preserving repitching."* Correct — the `readPos_ += ratio_`
path is **varispeed** (pitch and duration coupled). S16 is revised from "pitch envelope only"
into a **pitch-engine mode (Varispeed vs Preserve) + pitch envelope** (see §Pitch engine
modes below). Two new S16 forks are flagged: **S16-F1** (the engine default — lean Preserve)
and **S16-F2** (the Preserve implementation — lean `WDL_SimplePitchShifter` first, hand-rolled
held). The prior WDL finding that dismissed `WDL_SimplePitchShifter` is **corrected in place**
below (duration-preserving is now the requirement, so that shifter is the Preserve candidate).

### Play mode — Gate vs Trigger (S15)

Each played sample carries a **play mode** — a per-sample/per-zone **performance choice**
(D-B, instrument-owned, never a bank fact). Two modes, precisely:

- **Gate — classic held note (grows the current path).** Note-on enters the amp envelope;
  note-off enters release; a **sustain loop** applies for held notes (S11's draggable loop
  markers are Gate-mode UI). The current core envelope is **ADSR**; Gate adds a **Hold**
  stage → **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 today's ADSR** — a back-compat degenerate, no behavior change for existing Gate
  play. Segment math is the existing linear-ramp idiom (`AdsrEnvelope::tick`) with one new
  stage inserted between Attack and Decay.
- **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**. Envelope math (distinct from AHDSR): play the frame
  span `[startFrame, playEnd)` where `playEnd = startFrame + round(lengthFraction·(frames −
  startFrame))`, `lengthFraction ∈ (0,1]`; amplitude ramps `0→1` over `fadeInFrames`
  (fade-in) at the head and `1→0` over `fadeOutFrames` anchored to `playEnd` (fade-out),
  unity between; fades clamp so `fadeInFrames + fadeOutFrames ≤ play length`. The voice
  frees when `readPos_ ≥ playEnd` (mirror of the current run-off-end idle). **Fade curve
  default: equal-power** (constant-power `sin`/`cos` — click-free on one-shots); linear is a
  build-time residual. **Note-off in Trigger is a no-op** (choke is held — fork S15-F1).

**Both modes: modifiable start point.** Playback begins at `startFrame` (a frame offset into
the sample, clamped `0 ≤ startFrame < frames`), not always frame 0. This is the voice's
initial `readPos_`; the existing per-frame `readPos_ += ratio_` read and linear-interp/loop
machinery are otherwise unchanged. Gate additionally has **modifiable loop points** (already
the S2 loop intrinsic + S11 override); Trigger has none (it is a one-shot).

**Voice-stealing interaction (unchanged).** The S3 stealing policy (oldest-in-release, else
oldest-overall) is mode-agnostic — a Trigger one-shot is a normal active voice until it runs
off `playEnd`; it can be stolen like any voice. No new stealing rule.

**Confirmed from the core (`sampler_core.cpp`):** the read loop advances `readPos_` by an
arbitrary `ratio_` per frame with 2-point linear interpolation, and the amp is a per-frame
`env_.tick()` multiply — so both the AHDSR hold stage and the Trigger fade/%-length envelope
are **per-frame amplitude functions** over the existing read machinery, and the start point
is just a non-zero initial `readPos_`. No resampler or voice-lifecycle rewrite is needed.

**Parameter ownership (D-B).** The play mode + its params (Gate: AHDSR; Trigger: %-length +
fade-in + fade-out; both: start point) attach to the **capture selection / zone** and live
in the instrument's **performance map** (component state, version-bumped, back-compat: a
truncated/older blob defaults to **Gate, hold=0, start=0, no fades = exactly today**). Start
point joins `rootOverride` / loop-override as another per-`PerformanceZone` optional
override; a per-zone `PlayMode` + param struct is added additively. **Fork S15-F2 (flagged):**
per-capture-selection *and* per-zone, or per-zone only with the single-capture case as a
one-zone map? **Lean: per-zone only** — the single capture is already a one-zone map
(S10-Z's back-compat lift), so one storage site serves both; flagged because it touches
S10's single-capture setup surface shape.

**Editor (mode-aware, on the S11 waveform surface).** Gate shows draggable **start + loop
markers**; Trigger shows **start + %-length end + fade-in/out** handles — same waveform, same
pure `frame↔pixel` + marker-grab geometry module (S11), mode switches which markers draw. A
**mode toggle** per capture/zone sits in the S10 guided setup / S10-Z Zones panel. Every edit
commits **off-thread** via `commitMapAndReload`; the instrument stays a **read-only bank
consumer** (mode/params are performance map, never written to `Sample` or the bank).

### Pitch engine modes — Varispeed vs Preserve (S16)

**Daniel's correction (2026-07-26, verbatim):** *"isn't that ratio stuff going to change the
playback rate? I want duration-preserving repitching."* Correct: the `readPos_ += ratio_`
resampling path is **Varispeed** — pitch and duration are coupled (an octave up halves the
note's duration). Daniel wants **duration-preserving** repitch. So S16 grows a per-voice/
per-zone **pitch-engine mode**, not just a pitch envelope:

- **Varispeed engine (current path).** `ratio_ = pitchRatio(note,root)`, `readPos_ += ratio_`
  with 2-point linear interp — resampling that couples pitch and duration. This is the
  **classic sampler / RS5K** behavior and today's shipped S3/S5 output. Cheap, zero-latency.
  Musically right for **drums / one-shots** (pitch-down-lengthens-the-hit is a feature there).
- **Preserve engine (duration-preserving).** The read advances at the **source** rate
  (duration held) while a **pitch shifter** transposes the output by `2^((note−root)/12)`.
  Musically right for **tempo-locked loops and phrases** — a transposed loop still lines up to
  the bar. Since captured banks are project slices (loop/phrase-heavy), this is the default
  lean (fork S16-F1).

Mode is **per-`PerformanceZone` performance state (D-B)** — instrument-owned, never a bank
fact — additive/version-bumped (absent/older blob → the S16-F1 default). A per-zone
**Varispeed/Preserve toggle** surfaces in the S10 guided setup / S10-Z Zones panel.

**Preserve engine implementation (fork S16-F2).** Two RT-disciplined routes behind the
`PitchEngine::Preserve` seam (identical contract either way):
- **(a) `WDL_SimplePitchShifter`** (`vendor/WDL/WDL/simple_pitchshift.h`) — a per-voice
  time-domain OLA shifter. Under the duration-preserving directive this is **the right
  category** (see the corrected WDL finding below). `set_shift(2^(semi/12))` for pitch,
  `set_tempo(1.0)` to hold duration — pitch and duration are separately controllable. **Lean:
  route (a) first** (low-cost proof), with two costs owned in the build: an inherent
  **onset latency** (~half-window, ~25 ms @ the 50 ms quality-0 window; pre-warm at voice-
  allocation, and it lands on sustained/loop material where least harmful) and a **queue-growth
  allocation** hazard in `BufferDone` (`WDL_Queue::Add`) that is settled by a silence pre-warm
  at voice-allocation so no `process`-thread allocation occurs in steady state.
- **(b) hand-rolled pure `pitch_shift` OLA/granular module** (house pattern — CTest-testable,
  no REAPER/VST3/WDL type at the boundary) — **held** as the quality/latency upgrade if the
  SimpleWindowed warble or onset lag proves musically unacceptable.

**`WDL_Resampler` is not a Preserve engine** — it is a *resampler* (couples duration); it
remains a held **Varispeed-quality** upgrade only. **elastique is NOT available** (licensed
zplane, not vendored — restated). JUCE / rubberband / signalsmith are **new-dependency forks
carrying D-A weight** (bare-VST3-no-framework is the locked D-A) — **not proposed**.

**S15 × S16 interaction (Preserve consumes S15's source-frame read).** S15's amplitude
semantics are defined over the voice's **source-frame** timeline; the Preserve engine wraps
that read and transposes the output, so:
- **Trigger %-length** stays a source-frame fact (`playEnd = start + round(lengthFraction·
  (frames − start))`); under Preserve its **wall-clock is stable under transpose** — *cleaner*
  than Varispeed, where transposing a Trigger also scales its audible length.
- **Gate sustain loop** — under Preserve, **loop the source read** (the `[loopStart, loopEnd)`
  source-frame region) and feed the looped stream into the shifter, which transposes the
  **output**. Contract: *loop the source, shift the output*; loop points stay source-frame
  facts (S11 markers unchanged). Under Varispeed the loop read itself carries the pitch.
- **Start point** is a source-frame offset in both engines (engine-independent).

### Pitch envelope — AD, off by default, engine-aware (S16)

A per-voice **pitch modulation curve** riding on top of whichever engine — a short **AD**
(attack-decay) envelope that biases pitch over time. **Off by default** (so existing playback
is bit-identical under the same engine). The classic use is a percussive **pitch drop**.

- **Shape (lean, build-time residual): two-segment AD** — at note-on the pitch offset rises
  to `peakSemitones` over `attackFrames`, then falls to 0 (base pitch) over `decayFrames`.
  A **zero attack** gives the pure "start high, drop to base" percussive drop.
- **Range: semitones (±).** `peakSemitones` is signed; default depth range noted at build.
- **Applied per engine.** Under **Varispeed** the offset is a **per-frame multiply of
  `ratio_`** by `2^(pitchEnvSemitones(frame)/12)` (the effective read increment varies frame-
  by-frame at no structural cost — the same per-frame `tick()` idiom as the amp envelope,
  RT-safe, no `process` allocation). Under **Preserve** the offset is **added to the shifter's
  shift amount** — `set_shift(2^((note−root + pitchEnvSemitones(frame))/12))` — bending pitch
  without touching duration. Per-voice (polyphonic notes each run their own).
- **Ownership + editor.** Per-zone instrument performance-map state (D-B), additive/version-
  bumped (absent → disabled). Editor exposure folds into the S12 ADSR-editor tier: attack +
  decay + a ±semitone depth control, default-off (discoverable but inert until enabled).

### WDL pitch/resample surface — corrected finding (feeds S16, not a committed point)

**Corrected 2026-07-26 (Daniel's duration-preserving directive).** The prior sweep dismissed
`WDL_SimplePitchShifter` as "wrong tool (duration-preserving)". Under the directive,
**duration-preserving is the requirement**, so that header is the Preserve-engine candidate,
not a mismatch — a real viability assessment replaces the dismissal.

The **full** vendored WDL pitch/resample surface is `vendor/WDL/WDL/resample.h` and
`vendor/WDL/WDL/simple_pitchshift.h` — the **only** two pitch/resample headers; there is
**no** elastique / formant-preserving anywhere in the tree. Honest findings:

- **`WDL_Resampler` (`resample.h`) — sinc/linear resampler, RT-suitable.**
  `SetMode(interp, filtercnt, sinc, sinc_size≤64, sinc_interpsize)`; streaming
  `ResamplePrepare`/`ResampleOut` with `Prealloc`. Its sinc mode beats the core's 2-point
  linear interp for **Varispeed** base-repitch quality (less aliasing on large transpositions)
  at a real CPU cost. **A resampler couples duration** → a Varispeed-quality option, **not a
  Preserve engine.** Held as a Tier-2/3 Varispeed-quality toggle; not committed.
- **`WDL_SimplePitchShifter` (`simple_pitchshift.h`) — time-domain OLA, duration-preserving —
  the S16 Preserve-engine candidate (fork S16-F2 route a).** Viability from the header:
  - **API shape:** push/pull, block-based. `GetBuffer(size)` returns an input buffer to fill;
    `BufferDone(filled)` runs the OLA shift and queues output; `GetSamples(req, buf)` pulls
    from the queue. Config: `set_srate`, `set_nch`, **`set_shift(ratio)` (pitch, duration-
    preserving)**, `set_tempo(scale)` (an *independent* duration knob — Preserve uses
    `set_tempo(1.0)`), `SetQualityParameter(q)` (selects window/overlap ms from a fixed table).
  - **Per-voice instantiability / memory:** modest. `m_psbuf` is an OLA ring of `bsize·nch`
    where `bsize = window_ms · 0.001 · srate` (≈ 2205 frames at 50 ms / 44.1 kHz ≈ a few
    KB/voice), plus `m_inbuf` (one input block) and a bounded `m_queue`. `m_rsbuf` allocates
    only when `set_tempo ≠ 1` (unused in Preserve). One instance per voice is cheap in memory.
  - **RT-safety:** allocations occur in `BufferDone` — `m_psbuf.Resize` (once, when
    `bsize·nch` first sets, at a fixed quality/srate/nch — pre-warmable) and `m_queue.Add`
    (grows only until the push/pull cadence reaches steady state). **Pre-warm at voice-
    allocation** (run silence through once so `m_psbuf` sizes and `m_queue` settles); after
    that no `process`-thread allocation. No locks. **RT-viable with the pre-warm discipline.**
  - **Latency:** inherent ~half-window (initial `m_pspos = bsize/2` → ~25 ms @ 50 ms window)
    plus fill-up — a **real note-onset lag**. This is the load-bearing cost. Mitigation:
    pre-warm; and Varispeed (zero-latency) serves the tight-transient one-shot material, so the
    lag lands on sustained/loop material where least harmful. Smaller-window quality settings
    (the table goes to 3–10 ms) trade latency for more warble.
  - **Quality:** basic — this is REAPER's "SimpleWindowed" mode. Audible warble on large
    transpositions; **`set_formant_shift` is an explicit empty stub** → no formant preservation.
    Usable for loop/phrase Preserve; replaceable by route (b) if not.
  - **CPU / polyphony:** `PitchShiftBlock` is O(length) per block — a few mults + one OLA
    crossfade branch per frame, **no FFT**. Per-voice cost is modest; **N polyphonic voices
    each running one is feasible** within RT discipline. If the aggregate cost is material, a
    **Preserve-mode-specific voice cap** (below the Varispeed cap) is the pressure valve —
    flagged in Verify, set from measured per-voice budget at build.
- **Formant-preserving / studio-grade time-stretch (elastique-class): NOT in WDL, confirmed.**
  REAPER's elastique is **licensed (zplane)**, not in the vendored tree (grep found only
  unrelated libpng/giflib string matches). Formant-correct duration-preserving repitch is
  **unavailable without a new third-party dependency** (JUCE / rubberband / signalsmith each a
  new-dependency fork with D-A weight — not proposed). Stated, not worked around.
- **Recommendation:** the **Preserve** engine (S16-F2) is `WDL_SimplePitchShifter` (route a,
  low-cost proof) or a hand-rolled pure `pitch_shift` module (route b, held quality upgrade).
  The **pitch-envelope** modulation stays hand-rolled over whichever engine (a per-frame
  `ratio_` multiply under Varispeed, a per-frame shift-amount add under Preserve).
  `WDL_Resampler` (sinc) is a held **Varispeed-quality** upgrade only.

### Sequencing (S15/S16 against S7 stereo, S10 editor)

S15 and S16 are **S3-core extensions** — they touch the engine Daniel smoke-tests, like S7.
They are **channel-count-agnostic by construction**: the play-mode envelope is a per-frame
**amplitude** function, and both pitch engines carry the channel dimension internally — the
**Varispeed** path is a per-frame per-channel read-rate scalar, and the **Preserve** shifter
is **`set_nch`-aware** (one shifter instance per voice transposes all its channels together).
So S15/S16 **compose cleanly with S7's channel dimension** rather than conflicting: S7 adds a
channel axis to the read/mix; S15 adds an amplitude-shape axis; S16 adds a pitch-engine +
read-rate axis; all orthogonal. **Recommended order:** **S15 before S16** (S16 reuses S15's
per-voice param-plumbing + component-state version bumps; landing S15's `PlayMode`/param
struct first gives S16 a home to hang the pitch-engine mode + pitch-env params on). **S16 is
now meaningfully heavier than the prior "just an envelope" framing** — the Preserve engine is
a per-voice DSP object with its own RT budget, pre-warm, and possible voice-cap; treat S16's
Preserve-engine point as the phase's next real DSP spike, not a thin add-on. **S15/S16
relative to S7:** no hard dependency — spec them so the envelope/mode code never assumes a
channel count (it operates per-frame, pre-mix; the Preserve shifter is `set_nch`-driven), and
S7 can land before, after, or interleaved. **Relative to S10 (editor):** S15's mode toggle +
Trigger handles and S16's AD control **surface through** the S10/S11 waveform + guided-setup
work, so the *core* halves of S15/S16 can land independently of the editor, with the editor
surfacing following S10/S11 (the same way S12's ADSR editor follows the S3 ADSR math). Land
the **core** engine work (mode split, start point, %-length/fades, pitch-env modulation) as
soon as it is ready — it is testable in CTest without the editor — and wire the UI as the
S10/S11 surfaces mature. **Land S15/S16 core after S10's policy-reversal is settled** only if
sharing the same component-state blob would otherwise churn the version tag twice; otherwise
they are independent.

## Ingest through the bank — the extension owns ingest (decided "option 1", 2026-07-26; PLAN.md S8)

**Decided: loading a sample into the sampler is ONE gesture — capture/import-into-bank AND
auto-assign to the active sampler instance — and the *extension* owns it.** The instrument
stays a **read-only bank consumer**; it never captures and never imports. The extension is
the right owner: it has arrange access, Media-Explorer access, and the drop-target surface
on its own docked panels. Ingest lives in the *extension* codebase (actions + `bank_panel` +
capture/import add-path), routing through the existing capture add-path and the live
`"reasampler"` seam the instrument already reads.

**The three ingest surfaces, with the honest SDK reality (verified against the vendored
headers):**

- **Arrange capture → bank → assign.** A one-click action captures the selected item /
  time-selection into the bank (reusing the existing capture request path —
  `CountSelectedMediaItems` / `GetSelectedMediaItem` + `GetSet_LoopTimeRange` are already the
  capture inputs) and assigns the resulting `Sample` id to the target instance. **It never
  inserts a timeline item** — the capture/placement separation is load-bearing; assignment
  is a bank-index + instance-selection act, not a placement.
- **Media Explorer import → bank → assign.** The Media-Explorer surface is **thin**:
  `OpenMediaExplorer` (open/select a file) and `MediaExplorerGetLastPlayedFileInfo` (read the
  *one* last-played/selected file path + its selection range/pitch/vol/rate) are the whole
  contract. There is **no** enumerate-selected-files and **no** register-a-drop-handler-on-
  the-Media-Explorer API. So ME import is **single-file, pull-on-action** — an action fired
  while a file is selected in the ME — not a push/drop from inside the ME. **Spike:** confirm
  `MediaExplorerGetLastPlayedFileInfo` returns a usable path+range for a merely-*selected*
  (not-yet-played) file, or whether a play is required first.
- **Drag-and-drop onto ReaSampler surfaces.** REAPER exposes **no** drag-drop registration
  API. Drop handling is on ReaSampler's *own* HWNDs via SWELL/Win32 (`WM_DROPFILES` /
  `IDropTarget` on the docked `bank_panel` HWND — the surface the panel already owns) → ingest
  → assign. **Assess-and-flag (spike, not promised):** a drop *onto the VST3 editor window* —
  whether the `IPlugView` HWND can accept an OS file drop and **relay it to the extension as
  a bank-ingest request** (the instrument does not ingest; it forwards a request over an
  agreed seam). This crosses the two-artifact boundary and the relay is unproven; if gnarly,
  drop-onto-panel is the shipped path and drop-onto-editor is deferred.

**The assign seam.** The ingest action names the target instance (lean: the active/
last-focused instance, discovered via the host context the bridge already resolves) and hands
it the new sample id — the same instance-owned selection state S4 already persists, so a
reload picks it up. With the change-detection seam (below) the assignment refreshes
hands-free; without it the ingest action pokes the target instance's reload directly.

**Guardrail (load-bearing, restated):** ingest is an *extension* act. Any instrument code
path that captures, imports, inserts a timeline item, or writes back into the bank is a bug —
the instrument reads and plays only.

## Bank-generation change-detection — hands-free refresh (decided 2026-07-26; PLAN.md S9)

Instances reference sample **ids**. So a **recapture** (M10) landing under the same id — or
an **ingest** (S8) touching the active bank — should refresh playing instances **hands-free**,
without re-opening each editor. The missing trigger: a **bank-generation counter** in
`"reasampler"` ext-state.

- **Writer (extension).** A monotonic **bank-generation counter**, stamped into
  `"reasampler"` ext-state under a new forever-stable `ext_keys.h` constant, bumped on every
  bank-content mutation that changes what an instance would play (capture add, recapture-in-
  place, sample-remove, move/copy affecting the active bank). Additive to the persist blob;
  defaults to 0 for projects saved before the stamp exists.
- **Reader (instrument).** Poll the generation over the bridge on a safe **off-audio-thread
  cadence** (a UI/timer tick, **never** `process`), compare to the last-seen value, and call
  the existing off-thread `reloadFromBank()` on change — reusing S4's atomic pointer-swap
  handoff (graveyard-reclaim) so a mid-play refresh does not glitch. No new audio-thread work;
  no allocation in `process`.
- **Cadence + safety.** A low-frequency UI timer, coalescing multiple bumps between polls into
  one reload (build-time residual). The read already tolerates a stale value by design (it
  reloads on the *next* poll). **Must-verify before build:** no torn-read hazard on the single
  integer generation key for a bridge read on the instrument's UI/timer thread concurrent with
  an extension write.

This seam serves **both** S8 ingest and M10 recapture; the writer side is extension-only and
independent of S8, so it can land alongside either.

## Design-system foundation — moved to Phase L (2026-07-26)

> **The visual design language moved out of Phase S into its own Phase L.** The
> design-system content that stood here (the toolkit assessment, the shared LICE drawing
> kit **S0-DS**, and the dock-panel refresh **S14**) has been lifted into **Phase L**
> (Look-and-feel) on `dev`, taken up by a parallel team so Phase S feature work proceeds
> ungated. S0-DS is now **Phase L point L1** (the shared kit); S14 is now **L2** — and,
> per Daniel's DS-3 call, expanded from a light re-skin into a **thorough dock-panel layout
> redesign** that lays out the full M11-aware button inventory before applying the kit; the
> VST editor + embed-strip restyle is now the explicit **L3** point (gated on Phase S
> landing on dev). The design-system forks DS-1 (LICE + WDL free game, no external
> frameworks), DS-2 (Direction B "Neon Console" + Direction C's spectral keyboard strip),
> and DS-3 (thorough panel layout redesign) are all **SETTLED (Daniel, 2026-07-26)**.
>
> **Authoritative from here:** **PLAN.md §Phase L + CONTEXT.md §Phase L on `dev`**, and
> `docs/product/visual-design-language.md` (on `dev`). Phase S's **S10–S13 build their
> interaction UX with the current drawing and adopt the Phase L kit when it lands — they
> are not gated on Phase L.** LICE/SWELL-only, the pure-geometry-module discipline, RT
> discipline, and the read-only-over-bank / VST3-class-UID-unchanged guardrails all hold
> exactly as before — a visual refresh is not a data-ownership or compat event.

## ReaSampler 9000 — the UX overhaul (S10–S13; DAW-tested S1–S6, "the UX is awful")

**The bar is set: better than ReaSamplOMatic5000.** Daniel DAW-tested the S1–S6
instrument and the verdict was that it *works* but the UX is unacceptable — "this is
supposed to be better than ReaSamplOMatic5000." The S1–S6 editor was a spike-grade LICE
panel: a clickable sample list, zone rows each carrying **seven tiny ±1 nudge/delete
mini-buttons** (low-/low+/high-/high+/root-/root+/delete), text-only labels, **no keyboard
visualization, no waveform, no drag interaction of any kind, no scrolling** for long lists.
Setting a zone from C1 to C4 by clicking "+" thirty-six times is the catastrophe; the rest
(no way to *see* a sample, no loop editing by eye, unreachable rows past the panel bottom,
a fixed envelope) compound it. The overhaul is scoped as **S10–S13**, sequenced so the
friction Daniel feels every test pass is removed first.

### Workflow hierarchy (REVISED 2026-07-26 — Daniel; supersedes the keymap-first S10)

The overhaul is reframed around the **actual workflow**, not a keymap. Daniel's directive,
distilled: *a giant list of "item" blocks is visually useless; optimize for working with
individual captures, not a huge list of everything.* The settled hierarchy:

1. **Primary flow = one capture, fast.** Most instances play a **single capture**. The
   metric is **time-to-first-note**: open → pick a capture → see it (waveform/peaks) → play
   it. The default editor face serves this, not a zone table.
2. **Fresh instance is SILENT — nothing auto-selected (policy reversal of S4).** On open
   with no stored selection, the instrument plays **nothing** and shows a clear **empty
   state** ("pick a capture") — it does **not** auto-play sample #1. This deliberately
   reverses the S4 "first sample plays" convenience: the `selectSample` first-sample
   fallback and the processor's Tier-0 fallback that resolved it are removed; an empty
   stored id resolves to silence. (Recorded as a reversal, not a regression.)
3. **Capture browser, not an item list.** Scannable **cards/rows** with **peak thumbnails**
   (the `Sample` peaks bank_model already carries — the same data the dock panel thumbnails
   draw), name, and a **root/key badge** where present; **filterable by bank** (bank_book
   named banks). A "giant list of item blocks" is the anti-pattern — the browser is designed
   for scanning by eye.
4. **Graphic, descriptive controls with a guided fast path.** Once a capture is picked, a
   prominent, self-explanatory single-capture setup surface (root note, play-mode basics,
   level). The keyboard strip serves the **single-capture** case first (shows where the
   capture sits / its root); drag matters most when zoning.
5. **Zones demoted to secondary (nice-to-have).** Multi-zone keymap editing becomes an
   **opt-in "Zones" panel** (S10-Z), not the default face — "most of the time the zones
   won't be used." The keyboard-strip drag machinery is still built, but in service of the
   capture-first layout.

**What "better than RS5K" means, specifically (not vibes).** RS5K's genuine strengths —
match or beat each: (1) **drag a file straight onto it** loads the sample (our S13 relay);
(2) **note-start / note-end** range with a visual sense of the keyboard (our S10 keyboard
strip — RS5K's own range UI is two number fields, so a *draggable* strip beats it); (3) a
**waveform** with draggable start/end/loop markers (our S11); (4) **ADSR** sliders (our
S12); (5) velocity layers / round-robin (Tier 2 — held, not in this overhaul). RS5K's real
**weaknesses are our opening:** its **one-sample-per-instance** model forces track sprawl
(one RS5K per drum) and it has **no multi-zone view in a single instance** — ReaSampler
9000 is multi-zone in one instrument by design (S5), so the **opt-in Zones panel** showing
*all* zones at once is a capability RS5K structurally lacks. But per the reframe, the *default*
face is the single-capture fast path (browser + setup), and multi-zone is the demoted
nice-to-have. "Better than RS5K" = a fast single-capture browser where RS5K makes you drag a
file blind, direct-manipulation where RS5K uses number fields, multi-zone-when-you-want-it
where RS5K is one-shot, and bank-integrated ingest where RS5K is file-at-a-time.

**Constraints (unchanged — settled, do not re-open):** LICE/SWELL drawing only (no toolkit
change — D-A settled); **all layout/hit-test math in pure geometry modules** (mirror of
`mode_switch` / `editor_geometry` / `embed_strip`), the draw + drag-state machine in the
shell; RT discipline untouched (every edit commits **off** the audio thread via the
existing `commitMapAndReload` → off-thread `reloadFromBank` → atomic swap); the instrument
stays a **read-only bank consumer** (loop/root/ADSR edits are the instrument's *performance
map*, D-B — never written back to the bank); component-state persistence and
read-only-over-bank stay settled.

- **S10 — capture-first editor: browser + guided single-capture setup (REVISED 2026-07-26).**
  The default face is the **capture browser** (scannable cards with **peak thumbnails** from
  the `Sample` peaks bank_model carries, name, root/key badge; **bank filter** over bank_book
  banks) feeding a **guided single-capture setup** (root note, play-mode basics, level).
  Fresh instance is **silent, nothing auto-selected** — the S4 first-sample fallback is
  **removed** (empty stored id → silence + a "pick a capture" empty state). New pure modules:
  `capture_browser` (card/grid layout + hit-test) and `keyboard_strip` (key-span↔pixel via
  the `embed_strip` idiom; a **root marker** for the single loaded capture; `pixel→note`;
  drag-delta resolver; per-zone bar rect + edge-grab hit regions for the opt-in Zones panel).
  Shell extends the click-only `wndProc` to a `WM_MOUSEMOVE`/`WM_LBUTTONUP` drag-state
  machine with live feedback, one coherent edit on release. **Multi-zone keymap editing is an
  opt-in "Zones" panel (S10-Z), not the default** — the demoted nice-to-have; it reuses the
  same strip geometry + drag machine (edge = resize, body = move, key = root) and retires the
  seven ±1 nudge buttons per row. **Built with the current LICE drawing; adopts the Phase L
  kit (L1) when it lands** (drawn through the shared component kit rather than flat
  `LICE_FillRect`/GDI once available) — **not gated on Phase L**; the drag machine's
  `WM_MOUSEMOVE` tracking also lights the kit's hover states at near-zero marginal cost once
  the kit is present. **Boundary shifts (from the reframe):** the "sample list" S12 was to
  scroll/search **is now this browser** — the card layout, peak thumbnails, and bank filter
  are S10's; S12 keeps **scroll** + **type-to-filter search** *layered over* S10's browser
  (bank filter picks the bank, search narrows within it). The waveform S11 makes loop-editable
  is the same waveform S10 shows read-only for the picked single capture ("see it").
- **S11 — waveform view + draggable loop points.** Selecting a zone shows its sample's
  **waveform** (peaks via the existing `peaks` module over the shell's already-decoded PCM
  — no new decode/WAV path) with draggable **start/end/loop-start/loop-end** markers that
  **snap to zero-crossings** (the S2 zero-crossing-aware requirement). A dragged loop is a
  **per-zone loop override** (additive on `PerformanceZone`, same shape as `rootOverride`;
  seeded from the S2 bank intrinsic, never written back). Marker/waveform geometry pure
  (`frame↔pixel`, marker grab regions, clamp start≤end, zero-crossing snap helper).
- **S12 — scale + ergonomics.** **Scroll** (wheel + scrollbar) over **S10's capture
  browser** so a bank longer than the panel is fully reachable, and a **type-to-filter
  search** that narrows the cards by name, **composing with S10's bank filter** (bank filter
  selects the bank; search narrows within it). *(Boundary shift from the 2026-07-26 reframe:
  the browser card layout, peak thumbnails, and bank filter are now **S10's**; S12 = scroll
  + search layered over that browser.)* **Direct numeric entry** for zone low/high/root (a
  click-to-type field over the strip, for precision the drag can't hit — Zones-panel-scoped).
  An **ADSR editor** — four draggable controls over the S3 `AdsrParams` (the math already
  exists and is wired into the voice engine; today the envelope is a fixed default).
  Scroll/search/slider/entry layout pure; ADSR + (implicitly) any exposed parameters become
  per-instance component state (additive, version-bumped, back-compat).
- **S13 — drop-to-load (the S8 relay, in the editor).** Dropping an OS file / media item
  **onto the editor window** ingests into the bank + assigns to this instance — the RS5K
  "drop a file straight on it" affordance. **The instrument does not ingest:** the editor's
  drop handler **relays a bank-ingest request to the extension** (S8's `option 1`), which
  performs the capture/import + assign; refresh is hands-free via S9 (or a direct reload
  without it). **Cross-artifact relay is the S8-flagged spike** — proven-and-shipped or
  degrade to the docked-`bank_panel` drop path with a clear affordance. Never inserts a
  timeline item (capture/placement separation intact).

**Sequencing (recommendation, argued below in this section's tail).** S10 first — under the
reframe it now carries the **whole felt win**: the empty-state / no-auto-select fix, the
capture browser (peak thumbnails, bank filter) that replaces the useless item list, and the
guided single-capture setup that retires the nudge buttons. This is the entire "the UX is
awful" wound, and time-to-first-note is the metric it moves. S11 (waveform + loop) and S12
(scroll/search over the browser, numeric entry, ADSR) follow — both lean on S10's browser +
drag machine, and S11's waveform is the same surface S10 shows for the picked capture. S13
depends on S8's ingest seam, so it sequences after S8. Against the queued engine work: **S10
should land before or interleaved with S7 (stereo).** S7 is a real engine capability (stereo capture in true
stereo) and touches the DSP Daniel smoke-tests — but the *reason* he'll keep smoke-testing
is the editor, and today every test pass is taxed by the nudge-button UX. Fixing what he
feels first (S10) makes every subsequent S7 test less painful; there is no hard dependency
either way (S7 is engine/bus, S10 is editor/geometry — orthogonal). Honest counter: if the
stereo *sound* is the thing blocking real use, S7 first is defensible — but "it works, the
UX is awful" points at the editor as the live wound, so **S10 leads.**

## Product name — ReaSampler 9000 (Daniel, 2026-07-26)

The MIDI-playback instrument's product name is **ReaSampler 9000**. The extension stays
**ReaSampler** (capture + organization); the instrument is **ReaSampler 9000** (playback).
Set by Daniel on DAW-testing the S1–S6 instrument, alongside the UX-overhaul directive.

- **Propagate the display name** across user-visible surfaces: the VST3 class **display
  name** string in the factory registration, the **factory vendor/name strings**, the
  `IPlugView` editor **title band** (currently "ReaSampler Instrument"), the **S6 embed-strip
  label**, and the Phase S docs.
- **Do NOT change the VST3 class UID.** Instances in already-saved projects key off the
  class UID; changing it orphans every existing instance in every saved project. The UID is
  a forever-stable contract (mirror of the command-id / ext-state-namespace forever-stable
  strings).
- **S-NAME-1 SETTLED (Daniel, 2026-07-26): rename the binary filename too.** The on-disk
  module name is renamed to match the product (e.g. `reasampler_9000.vst3`), not just the
  display strings. Full rename surface: **CMake `OUTPUT_NAME`** on the second VST3 target,
  the **factory vendor/name strings**, the **editor title**, and the **embed label**. The
  **class UID stays locked** as the compat anchor.
- **Compat verification (must-DAW-verify before shipping the rename).** The working
  assumption is that REAPER **rebinds a saved instance by its VST3 class UID, not by the
  module filename** — so a filename rename with an unchanged UID keeps saved projects working.
  **This is a to-verify assumption, not a confirmed fact:** a web check surfaced a
  JUCE/VST3-replace-VST2 case suggesting REAPER's binding can be more nuanced than "UID only"
  (an FXID match is involved), so it is not safe to assert UID-only rebinding from source.
  **DAW-verify:** save a project with an instance under the old filename, rename the module,
  reopen, and confirm the instance rebinds and restores its state. If REAPER keys partly on
  filename, fall back to keeping the current filename (display-strings-only) and record that
  as the shipped choice.

## REAPER / Steinberg API surface (verify all signatures)

- **VST3 SDK (a new vendored dependency — vendor it at the spike).** `FUnknown` and the
  `IComponent` / `IAudioProcessor` / `IEditController` interface family; the
  `SingleComponentEffect` / `EditControllerEx1` / `AudioEffect` base classes; the class
  factory (`GetPluginFactory` + factory macros); `IPlugView` for the editor;
  `ProcessData` / `ProcessSetup` for the hot path. **Verify** interface members, the
  base-class overrides, factory-macro spellings, and the Windows module-export symbol
  names (`InitDll`/`ExitDll`/`GetPluginFactory`) against the vendored SDK at the spike —
  the framing doc flags several of these as experienced estimates.
- **REAPER VST-host bridge.** `hostcb` opcode `0xdeadf00d` (resolve API function by
  name) and `0xdeadf00e` (host context); the by-name resolution of
  `GetProjExtState`/`SetProjExtState`/`EnumProjExtState`. **Verify** against
  `vendor/reaper-sdk/sdk/reaper_plugin.h` + `video_processor.h` +
  `reaper_plugin_functions.h`.
- **Embedded UI (D-D, later point).** `IReaperUIEmbedInterface` and the embed
  message/lifecycle contract — verify against
  `vendor/reaper-sdk/sdk/reaper_plugin_fx_embed.h` before use.
- **VST3 bus arrangement (S7 channel mode).** `setBusArrangements` /
  `getBusArrangement` and REAPER's mono/stereo instrument-bus expectations — verify against
  the vendored Steinberg SDK + `reaper_vst3_interfaces.h`.
- **Ingest surfaces (S8).** `InsertMedia` is the placement path (untouched by ingest);
  `CountSelectedMediaItems` / `GetSelectedMediaItem` + `GetSet_LoopTimeRange` are the
  arrange-capture inputs (already the capture path's); `OpenMediaExplorer` +
  `MediaExplorerGetLastPlayedFileInfo` are the *whole* Media-Explorer contract (thin — no
  enumerate-selected, no ME-drop-handler). Drop handling is SWELL/Win32 on ReaSampler's own
  panel HWNDs — REAPER exposes **no** drag-drop registration API. All verified against
  `reaper_plugin_functions.h`.
- **Bank-generation seam (S9).** New forever-stable `ext_keys.h` key for the generation
  counter; read over the same bridge `GetProjExtState` path S4 already uses. No new API —
  confirm no torn-read hazard on the integer key.
- **WDL pitch/resample (S15/S16).** **Verified this pass:** `vendor/WDL/WDL/resample.h`
  (`WDL_Resampler` — sinc/linear resampler, couples duration → **Varispeed** path) and
  `vendor/WDL/WDL/simple_pitchshift.h` (`WDL_SimplePitchShifter` — time-domain OLA,
  **duration-preserving** → the S16 **Preserve**-engine candidate, fork S16-F2 route a) are
  the whole pitch/resample surface; **no** elastique / formant-preserving in the tree. **S16
  Preserve-engine (route a) must-verify at build:** (i) **pre-warm** `WDL_SimplePitchShifter`
  at voice-allocation (run silence so `m_psbuf` sizes and `m_queue` reaches steady state) →
  **no `process`-thread `WDL_Queue::Add` growth**; (ii) measure **per-voice CPU + onset
  latency** (window·srate) against the polyphony cap; (iii) set a **Preserve-mode-specific
  voice cap** if the per-voice cost demands one. The pitch-envelope modulation is hand-rolled
  over whichever engine. If the held sinc **Varispeed**-quality upgrade is taken, verify
  `WDL_Resampler` streaming/prealloc against the per-voice RT budget before use.
- **LICE/SWELL editor.** Reuses the `bank_panel` LICE/SWELL drawing surface; verify the
  `IPlugView`↔LICE window/bitmap bridge at the spike (window creation, sizing, event
  routing) — the least-trodden edge of the phase.
- **LICE design-kit surfaces — moved to Phase L.** The shared LICE drawing-kit surface
  verification (`LICE_GradRect`/`LICE_RoundRect`/AA lines/circles/beziers/polygons + the
  `LICE_CachedFont`/`LICE_IFont` font engine, and the vwnd drawing-craft references) now
  lives with **Phase L point L1** on `dev` — see CONTEXT.md §Phase L "LICE / WDL API
  surface". Phase S surfaces (S10–S13) adopt that kit when it lands; they are not gated on it.

## Drop-and-load — drag a capture onto a track's FX button (S17 spec)

**The gesture.** While a capture is dragged out of the `bank_panel`, a track's TCP **FX
button** becomes a drop zone. Dropping the capture there **instantiates a ReaSampler 9000
on that track with the dragged capture already loaded and selected for playback** — one
gesture from bank to playable instrument. This is the *third* integration gesture: capture
(extension), placement-into-arrange (extension), and now **placement-of-the-player**
(this wave). It is drop-and-load, not drop-to-arrange — no media item touches the timeline.

**Why it needs a new drag mode (the CF_HDROP path can't carry it).** Today's drag-out
(M11) becomes an **OS file drag** (`CF_HDROP` via `drag_out` + `drag_out_win`) the moment
the pointer leaves the panel client rect. REAPER's TCP FX button is **not** a native drop
target that instantiates a plugin-with-a-file, so this feature cannot ride the OS-drag
path: an OS drop of a WAV onto the FX area does not create "an instrument preloaded with
that WAV." It requires an **internal drag** where the extension itself tracks the pointer
over REAPER's own UI, detects the FX-button hover, and on release **drives the insert
itself**. The extension is the actor for the whole gesture.

**The two-part mechanism.**

1. **Internal-drag hover detection (extension-side, pure + shell).** The `drag_out` pure
   module gains a **third `DragGesture`** beyond `Internal` (bank-to-bank) and `OsDrag`
   (M11) — `InstrumentDrop`. The gesture decision is refined: leaving the panel client
   rect no longer *immediately* means OS-bound. Instead:
   - Pointer **inside** the panel client rect → `Internal` (unchanged bank-to-bank drag).
   - Pointer **outside the panel but still over REAPER's own window/UI** →
     `InstrumentDrop` (new — the shell hover-tracks the TCP FX button and highlights it).
   - Pointer **left REAPER entirely** (Explorer / another app) → `OsDrag` (unchanged M11).

   The pure module stays REAPER-free: it decides `InstrumentDrop` vs. `OsDrag` from
   position **plus an "over-REAPER's-own-UI" predicate the shell supplies** (the shell owns
   the REAPER window/hit query; the pure layer owns the set/boundary algebra). Mirror of how
   M11 kept `decideGesture` pure over a rect the shell supplied. The shell then resolves the
   pointer to a track + FX-button hotspot, highlights it, and on release drives the drop.

2. **FX-button drop → add-VST + load-capture (extension-side shell, then instrument
   seam).** On release over an FX button the shell:
   - Adds a fresh instance: `TrackFX_AddByName(track, "VST3:ReaSampler 9000", /*recFX*/
     false, /*instantiate*/ <negative>)`. **Verified present** in
     `reaper_plugin_functions.h`:
     `int TrackFX_AddByName(MediaTrack* track, const char* fxname, bool recFX, int
     instantiate)` — a **negative** `instantiate` always creates a new effect (per the
     header comment); the `"VST3:"` prefix selects the format. Captures the returned FX
     index (or `-1` on failure).
   - **Loads the dragged capture into that instance via the load-capture seam** (below).
   - Wraps the whole thing in one REAPER undo point (`Undo_BeginBlock2`/`EndBlock2`) so the
     gesture is one Ctrl-Z — the same discipline the bank verbs use.

**The ReaSampler 9000 load-capture seam (the hard coupling — MUST be added; does not yet
exist).** The Phase S spec today gives the instrument a **live-state *read* seam** (it
reads bank index + mapping from `"reasampler"` ext-state via the bridge — §The two seams)
but **no entry point for an external actor to say "this fresh instance should play *this
specific* capture."** Reading the bank is not the same as being *pointed at one sample*.
This wave is the reason to add that seam, and the seam lands **inside the instrument**
(the `phase-s` artifact), not the extension.

**Mechanism (SETTLED — (B) VST3 component-state injection).** Right after
`TrackFX_AddByName` returns the new FX index, the extension writes the instance's component
state directly — the same blob the instrument's `getChunk`/`setChunk` round-trips — with
the target capture pre-selected. Deterministic, no shared-state race, no cross-process
handshake; it uses the instrument's own persistence format. The state-set path is
`TrackFX_SetNamedConfigParm` — **verified present** in `reaper_plugin_functions.h`: `bool
TrackFX_SetNamedConfigParm(MediaTrack* track, int fx, const char* parmname, const char*
value)`, and the header documents the write-parms `vst_chunk` / `vst_chunk_program` as the
base64-encoded VST-specific chunk. So the injection call is
`TrackFX_SetNamedConfigParm(track, fx, "vst_chunk", <base64 blob>)`.

**Load-bearing caveat — `vst_chunk` is the plugin's own serialized chunk.** `vst_chunk` is
ReaSampler 9000's **own** base64-encoded serialized state (its `getChunk`/`setChunk`
FXP/FXB-style blob), **not** a raw VST3 `IComponent::setState` stream that REAPER
re-marshals into the plugin. The extension therefore has to construct **exactly the
instrument's own state-blob bytes** with the capture pre-selected — REAPER does not
translate a neutral state representation on its behalf. This makes the **component-state
blob format a shared cross-artifact contract** — one that is **still being defined in Phase
S** — and a **coordination dependency between the extension and the instrument:** both must
agree on the exact byte layout that ReaSampler 9000's `setChunk` accepts before either half
is final. The load-capture seam and the component-state persistence work (§Where the toggle
lives / component-state version bumps) share this one blob format.

**Rejected alternative — (A) fresh-instance ext-state handshake.** The extension writes a
small "pending load" hint into `"reasampler"` ext-state keyed to the target track/FX (a
capture id + a target GUID); a freshly-instantiated ReaSampler 9000 reads it on init via
the bridge it already uses, claims + clears the hint, and self-selects that capture. It
would keep the artifacts loosely coupled through the one ext-state seam they already share
and avoid the extension hard-coding the instrument's state format — but it **loses on the
claim/clear race:** "which instance claims which hint" needs a stable key and a
cross-process handshake to get right, and (B) sidesteps that entirely by writing the state
directly and deterministically.

**Coexistence with the OS drag-out (disambiguation contract).** The two OS-vs-internal
modes are disambiguated **by pointer location, not a mode toggle** — the user never picks
"OS drag" vs. "instrument drop"; the extension infers it from where the pointer is when
released. The M11 boundary (left the client rect) is *refined*, not replaced: leaving the
rect now asks "over REAPER's UI → InstrumentDrop, else → OsDrag." Both M11 OS drag-out and
the internal bank-to-bank drag must remain **byte-for-byte unchanged** in their own
regions — this wave only inserts a new middle case. Multi-capture payloads are a
disambiguation input too (see open question — instrument drop is naturally single-capture;
a multi-capture drag over the FX button is either rejected or loads the first).

**Precision / invariant implications (drop-and-load).**
- **Explicit user-driven placement — consistent with capture↔placement separation.** This
  is a *deliberate placement gesture*: the user chooses to put a playing instrument on a
  track, exactly as inserting an item into the arrange is a deliberate act. It does **not**
  auto-capture (the file already exists in the bank) and does **not** insert a media item
  into the timeline. It instantiates a *reader* of the bank on a track and points it at one
  already-captured sample. Capture, placement, and playback stay three distinct acts; this
  is placement-of-the-player, not a capture and not a timeline insert.
- **No private sample copy.** The instantiated instrument consumes the one authoritative
  bank (it resolves the WAV via the shared M4 project-relative machinery like any
  ReaSampler 9000 instance); the seam hands it a *reference* (a capture identity), never a
  copied file. Any path that copies bytes into the instance is a bug.
- **The internal drag stays pure-decidable and testable.** The new `InstrumentDrop`
  gesture is decided in the `drag_out` pure module (REAPER-free) over a shell-supplied
  predicate; the M11 `drag_out` unit tests must not regress.

**Open questions (Daniel / Phase S team to decide).**
- **Multi-capture drag over an FX button** — reject (only single-capture drags arm
  `InstrumentDrop`), or load the first / a keymap of all? Tier-0 leans reject-or-first;
  a multi-capture keymap load is a Tier-1 stretch.
- **FX-button hotspot vs. whole TCP.** Does the drop zone have to be the FX button
  specifically, or is dropping anywhere on the target track's TCP enough (simpler hit
  resolution, arguably clearer target)? Depends on what the SDK exposes (see must-verify).

**Must-verify before build (drop-and-load).**
- `TrackFX_AddByName` — **verified present** (`reaper_plugin_functions.h`): signature and
  the `"VST3:"`-prefix + negative-`instantiate` semantics confirmed from the header.
- **Pointer→track / FX-button hit resolution during a drag** — **not yet confirmed.**
  Candidates: `GetTrackFromPoint` / `GetThingFromPoint` (verify names + signatures against
  `reaper_plugin_functions.h`); whether the FX button specifically is addressable vs. the
  TCP as a whole is an open verification that also decides the "hotspot vs. whole TCP"
  question.
- **Instance state injection (seam mechanism (B) — SETTLED, load-bearing prerequisite)** —
  **verified present** in `reaper_plugin_functions.h`: `bool
  TrackFX_SetNamedConfigParm(MediaTrack* track, int fx, const char* parmname, const char*
  value)`, with the header documenting `vst_chunk` / `vst_chunk_program` as the
  base64-encoded VST-specific chunk write-parms. The injection call is
  `TrackFX_SetNamedConfigParm(track, fx, "vst_chunk", <base64 blob>)`. The remaining
  prerequisite is **not** the API but the **shared component-state blob format**: `vst_chunk`
  carries the instrument's *own* serialized chunk (its `setChunk` input), so the extension
  must construct exactly ReaSampler 9000's state bytes — the cross-artifact contract still
  being defined in Phase S. Blocks the drop half until the blob format is agreed.

## VST3 channel identity — the UID pair + the pairing surface (S18; extends Phase V V4)

**Decided (Daniel, 2026-07-26):** the beta/stable channel split Phase V V4 gave the
*extension* extends to the **ReaSampler 9000 VST3 instrument** — a beta-built VST pairs with
the beta extension only, a stable VST with stable only, both installable side-by-side in one
REAPER. This is the instrument-side companion to V4 and mirrors its philosophy exactly:
**one channel per binary; all channel identity derives from the ONE
`REASAMPLER_CHANNEL_IS_BETA` bit via the pure `app_version` module — no scattered `#ifdef`s
in the VST shell.**

**What is already isolated (structural, not added by S18).** The wire/data pairing is
already done and needs no per-key work: `ext_keys.h`'s `kProjExtNamespace()` delegates to
`app_version::extStateNamespace()`, so a beta-compiled VST's bridge reads `"reasampler_beta"`.
Every wire key — `banks`, `assign_request`, S9's bank-generation key (in-flight), S17's
component-state contract, and **any future key** — is a plain constant *under* that
namespace, so channel data-isolation is **structural: no per-key opt-in, and a future key
that forgets to isolate is impossible by construction** (it keys off the namespace accessor,
not a raw literal). What S18 adds is only the missing *plugin identity* layer.

- **The UID-pair invariant (the permanent commitment).** The VST3 class UID is the plugin's
  identity — a saved REAPER project records it and rebinds a saved instance by it. Today
  `reasampler_vst.h` holds **one** forever-locked UID (`kReaSamplerProcessorUID`,
  `REASAMPLER_PROC_UID_1..4`, S-NAME-1). A beta VST with the **same** UID cannot coexist with
  stable in one install (same UID = identity collision / arbitrary rebind). So beta needs its
  **own** forever-stable UID: a second constant, minted once, locked exactly as the first.
  **Invariant: BOTH UIDs are frozen forever once shipped; the channel bit selects which is
  compiled into this binary** (one `DEF_CLASS2`, one class per binary — not both classes in
  one binary; that mirrors V4's fully-isolated-binary philosophy and keeps a beta build from
  ever presenting the stable identity). Saved-project isolation follows directly: a project
  saved with beta instances rebinds only to the beta VST; a stable-saved instance opened
  where only the beta extension has banks resolves the stable UID and shows a clean empty
  "pick a capture" state (S10 policy), not an error.
- **Binary + display identity, channel-derived.** Mirror the extension's `OUTPUT_NAME` fork
  (`reaper_reasampler` / `reaper_reasampler_beta`): the VST3 module's on-disk name forks
  `reasampler_9000` / `reasampler_9000_beta`, its factory display name "ReaSampler 9000" /
  "ReaSampler 9000 beta", and its editor title band + S6 embed-strip label are channel-aware
  — **all sourced from `app_version` channel accessors (a VST-name accessor beside
  `binaryName()`/`dockTitle()`), never a literal in `reasampler_vst.h`/`vst_entry.cpp`.** The
  factory version string carries the `-beta` render where V4's `appVersion()` already does;
  vendor/url/email stay shared unless V4 qualified the equivalent (V4 kept the lane-name
  prefix shared — shared-where-V4-shares is the default).
- **The complete pairing surface (the guarantee to state, not new code).** A channel's VST
  talks to that channel's extension **only**, because (1) plugin identity — UID + filename +
  display — is channel-forked (above), and (2) **all** wire keys live under the
  channel-derived `kProjExtNamespace()`. The two together make pairing complete and
  structural: identity keeps the *plugins* distinct; the namespace keeps the *data* distinct.
  No per-key or per-seam isolation work is ever needed — S8's assignment key, S9's generation
  key, and S17's blob-injection key all inherit it. **Verify all identity/factory wiring
  against the vendored Steinberg SDK** (`DEF_CLASS2` / `INLINE_UID` / `FUID` from
  `pluginfactory.h` + `funknown.h`); the pure `app_version` name accessors are CTest-tested.
- **Fork S18-F1 (flagged — Daniel's call): mint the beta UID now vs. at first beta release.**
  Lean **mint now** — mirrors the stable UID (minted at the S1 spike, locked long before
  ship), removes a "remember to mint before shipping beta" landmine, zero cost for an
  unused-until-beta constant. The alternative (a locked-once placeholder replaced before the
  first beta VST ships) defers the commitment but adds a release-gate step. Flagged only
  because the UID is a forever commitment.

## Non-goals / guardrails

- **The instrument never captures and never inserts into the arrange.** Playback is a
  read-only act over the bank. Any instrument path that captures, places a timeline item,
  or writes back into the bank is a bug — reject in review.
- **The instrument keeps no private copy of the samples.** It consumes the one
  authoritative bank; per-instance sample stores are a non-goal (they refork the source
  the one-source-multiple-views instinct keeps single).
- **The instrument never ingests (S8).** Capture, import, and drop-ingest are *extension*
  acts; the instrument only reads and plays. A drop onto the editor window (if the spike
  proves it viable) is *relayed to the extension* as an ingest request — the instrument
  never writes the bank itself.
- **Channel mode is a performance choice, not a bank fact (S7).** The mono/stereo toggle is
  per-instance component state, never written to `Sample` or the bank (D-B). The bank's
  per-sample channel-count intrinsic is a *file fact*; the play mode is the instrument's.
- **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.** The voice engine / envelope /
  keymap / repitch module takes no VST3 or REAPER type at its boundary — the shell
  marshals. Any VST3 or REAPER type leaking into the core is a bug (the D3 split).
- **Additive to the extension.** The `Sample` intrinsic-field addition is additive
  (new optional fields; no existing field or `BankIndex` behavior changes); everything
  else in Phase S lives in the *second* artifact and does not alter the extension's
  M/D/B/R/V pillars.
- **Do not spec Tier 2/3.** Tier 2 is held (noted, not specified); Tier 3 is
  optional-forever. Do not let their feature lists drive Tier 0–1's build shape. **Note:**
  S7 stereo is *not* a Tier-2 feature — it is a channel-count dimension on the existing
  Tier 0–1 engine, orthogonal to Tier 2's velocity-layers / round-robin / per-sample trim.
  (S7's stereo loop read is the same loop the core already has, extended per-channel — not
  the Tier-2 "sustain loops" feature.) **Likewise S15/S16** (Trigger/Gate modes + pitch
  envelope) are Daniel-directed engine features on the Tier 0–1 core, *not* Tier 2/3 — the
  AHDSR hold, Trigger one-shot, start point, and AD pitch envelope are orthogonal amplitude-
  shape / read-rate dimensions, not the held velocity-layers / round-robin / filter work.
- **S15/S16 params are performance choices, not bank facts.** Play mode, start point,
  %-length, fades, AHDSR, and the pitch envelope are per-instance performance-map state
  (component state), never written to `Sample` or the bank (D-B). The bank carries file
  facts (root note, loop intrinsic, channel count); the instrument owns how they are played.
- **S15/S16 stay channel-count-agnostic (S7 interplay).** The mode/envelope logic is
  per-frame amplitude and read-rate, independent of the S7 channel dimension. Any S15/S16
  code that assumes a fixed channel count (mono) — rather than operating per-frame pre-mix —
  is a bug that would collide with S7. Spec and build them channel-agnostic.
- **Trigger ignores note-off; choke is out of scope (S15).** In Trigger mode note-off is a
  no-op and the one-shot plays to `playEnd`. Choke-on-note-off / choke-groups are held
  (fork S15-F1, Tier-3-adjacent) — do not add a choke path in S15.
- **Pitch envelope is off by default (S16).** Default-disabled → offset always 0 → the
  engine's un-modulated output → playback bit-identical to the same engine pre-envelope. A
  regression that applies pitch modulation when the envelope is off is a bug.
- **Pitch engine is a per-zone performance choice, not a bank fact (S16).** Varispeed vs
  Preserve is per-`PerformanceZone` component state (D-B), never written to `Sample` or the
  bank. The engine default is fork S16-F1 (**lean Preserve** — Daniel's call), with a
  prominent per-zone toggle so drum/one-shot zones opt into Varispeed cheaply.
- **Preserve engine is RT-disciplined (S16).** The `WDL_SimplePitchShifter` (or hand-rolled)
  Preserve path **pre-warms at voice-allocation** and does **no allocation in `process`** — a
  `WDL_Queue::Add` or `Resize` on the audio thread in steady state is a bug. Preserve's onset
  latency (shifter window) is an accepted property, **not** a defect; a note-onset **click or
  smear** from a cold-started (un-pre-warmed) shifter **is** a bug.
- **`WDL_Resampler` is not a Preserve engine (S16).** It is a resampler (couples duration) —
  a held Varispeed-quality option only. Do not wire it as the duration-preserving path.
- **Drop-and-load must not regress the two existing drags.** S17 inserts a new middle case
  (`InstrumentDrop`) between the M11 OS drag-out and the internal bank-to-bank drag; both
  existing gestures stay byte-for-byte unchanged in their own regions. Drop-and-load never
  inserts a media item into the arrange and never copies sample bytes into the instance —
  it hands the new instance a *reference* to an already-captured bank sample.
- **Verify Steinberg SDK, bridge, embed, and LICE-view surfaces** against the vendored
  headers before use — several §1a claims are experienced estimates until the spike
  confirms them.

## Editor view-model redesign — three views: Sample / Browse / Zone (S-VIEW; Daniel, 2026-07-27, r9)

> **Additive sub-phase of Phase S — an *editor* redesign, not an engine change.** Re-partitions
> the ReaSampler 9000 editor from today's two-view toggle (Browser + Zones) into a **three-view
> model where the loaded sample is the home**, adds three new performance parameters (key-tracking,
> preview velocity, and the r10 velocity→amp transfer curve) and three visual components (envelope
> overlay, real piano-key pattern, and the r10 velocity-curve editor), and
> frames two engineering prerequisites (drop-to-FX bug, default window size). The S3 voice
> engine, keymap resolution, and read-only-over-bank contract are **unchanged**; the
> component-state format extends additively (key-tracking + the r10 velocity curve on the
> zones-payload axis, preview velocity on the envelope axis). Product framing: `docs/product/
> midi-playback.md` §Addendum r9 + r10. Same standing discipline: **LICE/SWELL drawing only,
> all layout/hit-test in pure geometry modules, RT-safe, VST3 class UID unchanged, verify every
> API name/signature against the vendored headers before use.**

### The view model — Sample is home; Browse is modal; Zone is a dedicated surface

The three views are **not a flat three-way toggle** (today's Browser|Zones segmented switch is
retired). The model is **document-with-modal-picker** (mirror: Ableton Simpler — the device face
*is* the loaded sample; loading a new one is a distinct act):

- **Sample (the home / default face).** What the editor shows on open with a capture loaded. The
  hero waveform, the envelope overlay, all per-sample tuning controls, the preview-trigger. This
  is where the user lives.
- **Browse (a modal page layered over Sample).** Summoned by a **Browse button** (and, when
  nothing is loaded, the empty-state's primary affordance — Browse must be *very* easy to open
  when there is no capture selected). It renders as a **full-window overlay** over the Sample
  face — filters + captures grid + **select-then-confirm** to change the loaded sample, then
  dismisses back to Sample. It is a picker sheet, not a peer tab.
- **Zone (a dedicated editing surface).** Opened by its own **Zone button** when the user wants
  to map the capture(s) across the keyboard. Not shown by default ("most of the time zones won't
  be used" — the S10 reframe). Returns to Sample on close.

**Navigation contract.** From Sample: a Browse button opens the Browse overlay; a Zone button
opens the Zone surface. From Browse: select a card + confirm (or cancel) returns to Sample with
(or without) a new loaded capture. From Zone: a close/back affordance returns to Sample. The
empty state (no capture loaded) surfaces Browse as its dominant call-to-action. **The fresh
instance stays silent with a "pick a capture" empty state (S10 reversal, unchanged).**

### View 1 — Sample (the new main view)

The home face. Composition follows the reference devices (Simpler / Phase Plant): a **hero
waveform up top with the envelope drawn over it at accurate time**, a **dense labelled
value-strip beneath**, **root fenced as its own affordance**, and a **preview cluster**. Bands,
top to bottom:

- **Title band.** Plugin name (channel-derived `vstPluginName()`) + live readout (loaded capture
  name / `[pick a capture]` / `[bank empty]` / `[host: no bridge]`) + the **Browse** and **Zone**
  view buttons. (Inherits today's title-band text logic.)
- **Hero waveform band (enlarged) with the ENVELOPE OVERLAY (new).** The picked capture's
  full-resolution envelope (from `peaks` over the cached mono PCM — no new decode), given real
  vertical space (the Simpler/Phase-Plant hero, materially taller than today's 72px strip). Drawn
  over it, at **accurate wall-clock time**: (a) the **S11 markers** — start (teal/secondary),
  loop start/end (purple/tertiary), the faint loop-region fill — moved here from Browse
  unchanged; and (b) the **NEW amplitude-envelope overlay** — the AHDSR shape (Gate) or the
  fade-in/%-length/fade-out shape (Trigger) traced as a curve across the sample at the same time
  base the voice engine uses, in an accent hue. This is the "reads like a real sampler, not a
  spreadsheet" move: the envelope becomes a *shape over the sound*, not four abstract sliders.
- **Root + preview cluster (fenced, new preview-trigger).** Root note shown as a first-class,
  always-visible control (fenced like Phase Plant's "Root" box), draggable/typeable — this is the
  keyboard-strip root-drag from today's Browse setup, promoted to a fenced control. Adjacent: the
  **NEW preview-trigger button** (fires the sampler at the loaded capture's root note through the
  live voice engine, off the audio-thread commit path — no MIDI controller needed) with an
  **adjacent velocity knob** setting the preview velocity level. The **Mono/Stereo toggle** moves
  here from Browse (it is a per-capture output-mode tuning concern, not a choosing concern).
- **The "Modes-and-down" control strip (moved from Zone).** Every per-sample control that lives
  on today's Zones param panel for the single-capture case — **Mode** (Gate/Trigger), **Pitch
  engine** (Varispeed/Preserve), the **AHDSR** sliders (Gate) or **Length%/Fade-in/Fade-out**
  (Trigger), and the **AD pitch envelope** (Off/On + P.Attack/P.Decay/P.Depth) — renders here as
  the Sample view's control strip, laid out as a dense labelled value-row (bold micro-caps over a
  value/slider, the reference grammar). For a single loaded capture this is the same one-zone
  storage site the S15-F2 lean already established (the empty-map single-capture face reads/writes
  the same `PerformanceZone` defaults) — no new storage.

**Browse elements that MOVE to Sample (inventory — nothing silently dropped):** the large
waveform preview, the S11 start/loop markers + loop fill, the keyboard-strip root affordance (now
the fenced root control), and the Mono/Stereo toggle.

### View 2 — Browse (reduced to *choosing*, laid over Sample as a modal)

Browse's only job is **pick a capture**. Today's Browser view is close but overloaded; it is cut
to the choosing essentials and rendered as a modal overlay:

- **Kept (the excellent core, unchanged):** the **type-to-filter search box**, the **bank filter
  tabs** (All + one per named bank), the **captures grid** (cards: peak thumbnail + name +
  root/key badge), **scroll** (wheel + thumb), and card **selection**.
- **Added:** a **confirm/cancel** affordance (select a card, confirm to load it into Sample and
  dismiss; cancel to dismiss unchanged) — the modal-picker close semantics. Double-click-to-load
  is the natural accelerator.
- **Removed from Browse (moved to Sample or cut):** the **large waveform preview** (redundant —
  the grid thumbnails already show every capture's waveform; moved to Sample as the hero); the
  **Mono/Stereo toggle** (moved to Sample); the **root-note keyboard-strip** in the setup band
  (moved to Sample as the fenced root control); the **loop-point labels** and the **track-root
  message** (cut — they waste space and add nothing to *choosing*).

**Rationale (why cut, not keep-and-hide):** Browse is a picker. Every tuning affordance on it is
a mode error — you tune what you *have*, you browse for what you *want*. Concentrating tuning on
Sample and choosing on Browse makes each view do one job, and makes the modal overlay light
enough to summon and dismiss without ceremony.

### View 3 — Zone (dedicated keyboard-mapping surface + key-tracking + real piano pattern)

Zone is the multi-zone keymap editor — RS5K structurally lacks a multi-zone-in-one-instance view,
so this is a genuine capability, kept as the deliberate secondary surface. Retains today's Zones
view wholesale, with two changes:

- **Kept (inventory — nothing silently dropped):** **+ Add Zone** / **Delete** buttons, the
  **keyboard strip with one bar per zone** (selected zone lit accent-primary + static glow, others
  categorical), the **selected-zone legend** with the three **Low/High/Root numeric-entry fields**
  (click-to-type, `parseNoteEntry`), and the **per-zone param control panel** (Mode, Pitch engine,
  AHDSR/Trigger, AD pitch envelope) — the same panel Sample now also hosts for the single-capture
  case (one storage site, two surfaces).
- **NEW — key-tracking parameter (0%–200%, default 100%).** A per-`PerformanceZone` scalar on how
  sample pitch tracks the keyboard around the root note. **100% = standard 12-tone-ET tracking**
  (today's behavior, bit-identical); **0% = no tracking** (the sample plays at root pitch on every
  key — a fixed one-shot); **200% = double-rate tracking**. Additive on `PerformanceZone`,
  version-bumped, defaulting to 100% so pre-existing zones are unchanged. Applies inside the
  repitch math in **both** engines: under **Varispeed** it scales the semitone offset feeding
  `pitchRatio(note, root)` (`effectiveSemis = (note − root) · keyTrack`); under **Preserve** it
  scales the semitone offset feeding `set_shift(2^(effectiveSemis/12))`. Surfaces as a control in
  the Zone param panel (and, for the single capture, on the Sample control strip). **The pure
  sampler core owns the key-track math** (unit-tested: a known note/root/keyTrack triple asserts
  the expected ratio); the shell only maps the 0–200% control to the scalar.
- **NEW — real piano-key pattern on the keyboard strip.** Today's spectral strip is pretty but
  does not read as a keyboard (Daniel's note). Keep the pastel spectral hue as the backdrop, but
  **overlay the actual alternating white/black key pattern** — bright cells for naturals, dark
  cells for accidentals (C#/D#/F#/G#/A#), per the palette (bright ≈ a light neutral, dark ≈
  `bg/base`/hairline) — so the strip is instantly identifiable as a keyboard. It need not be
  *shaped* like a keyboard (no protruding black keys); it carries the **pattern** as an overlay,
  so a glance reads pitch position without counting. The pure `keyboard_strip` geometry gains a
  `isBlackKey(note)`/per-key-natural query (12-tone pattern, pure + unit-tested); the shell draws
  the two-tone overlay over the spectral fill. This same strip serves the Sample view's fenced
  root affordance (one keyboard grammar everywhere).

### New parameters — ownership and persistence (D-B; instrument-owned, never bank facts)

- **Key-tracking** — per-`PerformanceZone`, additive/version-bumped component state, default 100%.
  A **performance choice**, never written to `Sample` or the bank. Back-compat: an absent field on
  an older blob → 100% (bit-identical playback).
- **Preview velocity** — a **utility** setting for the Sample view's preview-trigger button, not a
  musical parameter of the capture. **SETTLED (S-VIEW-F1, Daniel 2026-07-27): it PERSISTS across
  reloads.** The seam is **VST3 component state**, not the extension's `persist` ext-state module.
  This distinction is load-bearing and was verified against the existing VST source, not recalled:
  - **Wrong seam — the extension's `persist` module.** `persist` writes REAPER *project* ext-state
    (`SetProjExtState`, namespace `"reasampler"`) and is owned by the **extension**, not the
    instrument. Preview velocity is a **per-instance** instrument-editor setting; putting it in
    project ext-state would (a) make it project-global rather than per-instance (two ReaSampler 9000
    instances would share one preview level), (b) route an instrument-owned setting through a seam
    the instrument only *reads* from (bank state), violating the read-only-over-bank contract, and
    (c) leak an instrument concern into the extension's key space. Rejected.
  - **Right seam — the instrument's own VST3 component state**, the same blob the processor already
    round-trips via `ReaSamplerProcessor::getState`/`setState` over `IBStream`, whose format is the
    **envelope-versioned `ComponentState`** in `src/vst/sample_map.h` (currently **v5**: version tag
    + channel-mode byte + last-consumed-assignment marker + selection-id + zones payload). Preview
    velocity is a **top-level instance concern** — a sibling of `channelMode` and
    `lastConsumedAssignGeneration`, **not** a per-`PerformanceZone` field (it is one setting per
    instance, not per zone). It therefore lands as a **new field on `ComponentState`** added by
    bumping the **envelope version to v6** (a new `float previewVelocity` after the assignment
    marker, before the selection-id), leaving the **zones payload untouched** — exactly the
    independent-version-axes composition the header already documents (envelope grows a field; the
    zone-record payload version does not move). Round-trip: `serializeComponentState` appends it,
    `deserializeComponentState` reads it; a v6 blob restores it directly, and **every older blob
    (v5 and down) lifts to a sensible default** (e.g. 100/127 ≈ 0.79 or a chosen mid level), so
    already-saved instances are unchanged and no compat surface is broken. This is the same additive
    envelope-bump discipline S7 (v4→v5) already used — a well-trodden move in this codebase, not new
    machinery.

### The envelope overlay — visual component (new)

The amp envelope drawn as a curve over the Sample view's hero waveform at accurate wall-clock
time (the Simpler/Phase-Plant grammar). Gate → the AHDSR shape (attack ramp, hold, decay to
sustain, release tail); Trigger → the fade-in/unity/%-length/fade-out shape anchored to `playEnd`.
The time base is the same the voice engine resolves (seconds → frames at the live rate), so the
drawn shape lines up with the waveform under it. **Pure geometry:** an `envelope_overlay` module
(mirror of `waveform_view` / `param_slider`) maps the AHDSR/Trigger params + the sample's
frame-length to a polyline in the waveform rect (`param↔pixel` at the shared time base),
unit-tested against known param sets; the shell traces it via kit line draws in an accent hue.

**The overlay is directly editable — DRAGGABLE NODES (SETTLED, S-VIEW-F2, Daniel 2026-07-27).**
The envelope is not a read-only informative curve — its breakpoints are **draggable handles** that
set the envelope parameters directly. Dragging a node and the existing sliders are **two surfaces
onto one model**: the sliders stay as the precise numeric-entry surface, node-drag is the direct-
manipulation surface, and **both read and write the same `PerformanceZone` envelope fields** — a
drag updates the params, the sliders reflect them live, and a slider edit re-lays the nodes. There
is exactly one source of truth (the zone's envelope params); the two surfaces never diverge. This
is the same one-source-multiple-views instinct the whole system runs on.

- **Which nodes, and what each axis means.** The AHDSR shape (Gate) exposes handles at the segment
  breakpoints — **attack-end** (top of the attack ramp), **hold-end**, **decay-end / sustain-level**
  (the corner where decay settles to the sustain plateau), and **release-end**. The Trigger shape
  exposes **fade-in-end**, **%-length** (the `playEnd` anchor), and **fade-out-end**. For each node,
  **the horizontal axis maps to time** (the segment duration — attack/hold/decay/release seconds, or
  fade/length fractions) and **the vertical axis maps to level** where the node is a level breakpoint
  (the sustain node's vertical drag sets sustain 0..1; the peak nodes sit at unity). Time-only nodes
  (attack-end, hold-end, release-end) drag horizontally; the sustain node drags on **both** axes
  (its X sets decay time, its Y sets sustain level) — the standard ADSR-editor grammar (Ableton
  Simpler, Phase Plant, Serum all use exactly this).
- **Constraints.** Nodes are **monotonic in time** — a node cannot be dragged left of its
  predecessor or right of its successor (attack-end can't pass hold-end, etc.); each segment stays
  ≥ 0. Level drags clamp to **0..1** (sustain) / unity (peaks). Times clamp to the same per-param
  min/max the sliders already enforce (so node-drag can never produce a param the slider couldn't).
  The `playEnd`/%-length node additionally clamps to the sample's frame-length. **No snapping** by
  default (continuous drag, matching the sliders' resolution); a fine-drag modifier (drag with a
  held modifier for reduced sensitivity) is an optional polish, not required.
- **Pure module owns the geometry/hit-test math (house pattern).** A pure REAPER/LICE-free module —
  name it **`envelope_edit`** (sibling to `envelope_overlay`; mirror of `card_drag` / `mode_switch`
  / `param_slider`) — owns **node hit-testing** (point → which node, with a pick radius) and the
  **drag→param mapping** (a pixel delta on a given node → the resulting clamped envelope param set,
  respecting the monotonic + range constraints above). It is **unit-tested**: a known node + a known
  pixel delta asserts the expected param delta and the clamp/monotonic behavior at the boundaries.
  `envelope_overlay` keeps the params→polyline forward map (draw); `envelope_edit` owns the
  pixel→params inverse map (edit) + hit-test. The shell (`reasampler_editor.cpp`) does the LICE
  handle draw (small node markers at each breakpoint, hover/drag-lit via kit states) and routes
  mouse events through `envelope_edit`, then commits the resulting params to the zone through the
  same off-audio-thread path the sliders use (no new RT surface — a node-drag is a param edit, same
  as a slider drag). Zones and the single-capture Sample face share this one edit surface (one
  storage site, S15-F2).
- **Sync with sliders (load-bearing).** Because both surfaces write the same `PerformanceZone`
  envelope fields, keeping them in sync is **structural, not a listener chain**: the editor re-reads
  the zone's params every paint, so a slider edit re-lays the nodes and a node-drag re-positions the
  sliders with no explicit cross-wiring. The single source of truth makes divergence impossible by
  construction.

### The real piano-key pattern — visual component (new)

Covered under View 3 above. Pure: `keyboard_strip` gains the natural/accidental predicate; shell
draws the bright/dark overlay over the existing pastel spectral fill. Shared by the Zone strip and
the Sample root affordance.

### The velocity → amp transfer-curve editor — visual component (new; Daniel, 2026-07-27, r10)

A **visual transfer-curve editor** mapping MIDI velocity to an amp scalar: **X = velocity (0–127),
Y = amp scalar (0–1)**, an editable bezier from a flat default to an arbitrary multi-point curve.
It gives fully shapeable velocity dynamics per sound. Today the engine maps velocity to gain
*linearly* — `velocityGain_ = velocity / 127.0`, computed once at note-on in `Voice::start()`
(`src/vst/sampler_core.cpp:261`); this replaces that fixed line with an editable curve evaluated at
the same point.

**State home — per-`PerformanceZone` (instrument-owned, D-B).** Velocity response is a per-sound
performance characteristic — a sibling of the AHDSR amp envelope, the pitch engine, and the r9
`keyTrack` scalar, all of which already live on `PerformanceZone`. A drum and a pad want different
velocity curves, so the curve varies **per zone**, not per instance. This is deliberately **not**
`ComponentState` (per-instance): that is where **preview velocity** correctly lives, because
preview velocity is a *utility* setting (one per instrument, like a metronome level), whereas the
transfer curve is a *musical* setting (one per sound). The curve is an **additive field on
`PerformanceZone`** riding the **zones-payload version axis** (contrast preview velocity's
envelope-v6 bump — a top-level per-instance field on the *envelope* axis; the two version axes are
independent, as the header documents). The single-capture Sample face reads/writes the same
one-zone storage site (S15-F2), so Sample and Zone views share one curve store per zone.

**Default — flat y=1, a deliberate behavior change (fork R10-F1, Daniel's call).** Daniel's
verbatim default is *"any velocity plays at full level"* — a flat curve at y=1. This is **not**
bit-identical to today's shipped linear `velocity/127` map: today soft hits are quieter; under a
flat-y=1 default every hit plays at unity, so every existing zone's felt dynamics change. This is
the one genuine fork the feature carries:
- **Option A (Daniel's stated default): flat y=1.** Honors the directive; velocity is inert until a
  curve is drawn. *Con:* NOT back-compat — already-saved instances and new captures get flatter
  dynamics than today until a curve is drawn.
- **Option B: default = today's linear ramp (y = x/127).** Bit-identical to the shipped engine; the
  editor's flat y=1 is one drawn state, not the default. *Con:* contradicts the verbatim *"by
  default … full level"* (the default line is a diagonal, not flat).
- **Lean: Option A (flat y=1)** — it is what Daniel asked for and the feature's point is opt-in
  velocity dynamics — but flagged loudly as a **shipped-behavior change**, not a silent regression.
  Option B is the safe fallback and costs only the default curve's seeded control points. Whichever
  wins, the *stored* default is a curve the editor draws and the core evaluates; the options differ
  only in which curve is seeded.

**Pure module — `velocity_curve` (REAPER/LICE-free, unit-tested).** Mirror of `envelope_edit` /
`card_drag`. Two responsibilities:
- **Evaluation:** `eval(velocity 0–127) → amp scalar 0–1` — a bezier through the control points,
  clamped to the 0–127 × 0–1 box, **monotonic in x** by construction (each velocity has exactly one
  output). Called at note-on, never per frame.
- **Editing:** add / move / delete control points, each clamped into the box and **x-ordered** (a
  point cannot cross its neighbours in x — the same monotonic grammar as the envelope nodes); a
  point hit-test (point → which control point, pick radius) and a pixel-delta → clamped-point
  inverse map (mirror of `envelope_edit`'s inverse map). The flat identity curve (per R10-F1) is a
  named constructor.

Both halves are **unit-tested** at the boundaries: a known curve + known velocity asserts the eval
output; a known drag asserts the clamped point set + the box/order constraints; add/delete assert
the point count and ordering invariants.

**Voice-engine application point — `Voice::start()`, off the per-frame path.** Confirmed from
source: `Voice::start(int note, int velocity, …)` (`sampler_core.cpp:252`) computes
`velocityGain_ = velocity / 127.0` **once at note-on** (line 261); the per-frame render then just
multiplies the cached scalar (`advanceFrame`, line 408: `gain = amp * velocityGain_`). The transfer
curve slots in at exactly that line — `velocityGain_ = curve.eval(velocity)` at note-on — **off the
audio-thread-hostile per-frame path** (evaluated once per voice, no new `process`-thread work, no
allocation). The curve reaches the voice the same way the AHDSR/keyTrack params do: on the zone's
`SampleData::play` bundle (resolved from the stored `PerformanceZone` at keymap build), read by the
voice at `start()`. The pure `velocity_curve` core owns the eval; the voice reads it.

**The curve-editor UI — Sample view, adjacent to the envelope overlay.** Lives on the **Sample
view** (the r9 home face), a compact band next to the hero-waveform envelope overlay — the two are
the same grammar (a drawn 2-D curve with draggable handles), and amp-over-time beside
amp-over-velocity reads naturally. Draws through the **L1 kit** like every S-VIEW surface: a
bordered box (X = velocity 0–127, Y = amp 0–1), the bezier traced in an accent hue, small draggable
node markers per control point (hover/drag-lit via kit states), add-point on empty-space click,
delete on modifier-click / drag-off. The shell (`reasampler_editor.cpp`) does the LICE draw + mouse
routing; **all geometry/hit-test/clamp math lives in the pure `velocity_curve` module**. On the
Zone view the same editor appears in the per-zone param panel (one curve per zone). Additive and
bit-identical for existing projects only under R10-F1 Option B; under Option A (the lean) it is
additive-but-behavior-changing.

**Wave-plan slot (concurrency-aware).** The curve lands on `PerformanceZone`, so it is **blocked by
Wave 1 track T-KEYTRK** (which owns the `PerformanceZone` schema + zones-payload version bump). The
`velocityCurve` field must sequence as a **LATER additive payload bump AFTER T-KEYTRK merges** — the
two are sequential additive extensions of the same zones-payload record, not simultaneous ones, so
they never collide on one payload version. It is **not** blocked by T-STATE (that owns the
per-instance `ComponentState` v5→v6 envelope bump for preview velocity — a different struct on the
independent envelope version axis). Concretely: a **follow-on foundation track** (pure
`velocity_curve` + the `Voice::start` application point + the additive `PerformanceZone` field +
payload bump), gated on T-KEYTRK; plus a **Wave 2 shell-integration item** (the Sample-view +
Zone-panel curve-editor UI through the L1 kit), gated on the foundation track and composing with the
S-VIEW-2 Sample face + S-VIEW-3 envelope overlay.

### Engineering prerequisite 1 — drop-to-FX bug (routed to implementation, NOT a design call)

**Symptom (Daniel):** dropping a capture onto a track's FX chain does not instantiate + init
ReaSampler 9000 — the audio-to-arrange drop works, but the "instrument init never fires."

**SDK sweep (done this pass — no false ceiling asserted):** the S17 drop-to-FX machinery is
**present and SDK-correct**. `src/instrument_drop_win.cpp` calls `TrackFX_AddByName(track,
"VST3:" + vstPluginName(), /*recFX*/false, /*instantiate*/-1)` then injects component state via
`TrackFX_SetNamedConfigParm(track, fx, "vst_chunk", <base64>)`; both signatures + semantics are
**verified present** in `reaper_plugin_functions.h` (the `VST3:` prefix, negative-instantiate =
always-new, and the `vst_chunk` base64 write-parm are all documented). The gesture is wired
(`src/bank_panel.cpp`: `decideGesture` → `InstrumentDrop` → on release `buildInstrumentDropChunk`
→ `performInstrumentDrop`), FX-hotspot detection uses `GetThingFromPoint` (`fx_*` prefix), and the
factory registers `kInstrumentSynthSampler` correctly. **So the platform CAN do this — the
capability is not in question; the wiring is present.** This is therefore a **runtime diagnosis
task, not a design decision or a platform-ceiling problem.** The likely fault domains for
staff-engineer to investigate (in order):
1. **FX-name resolution:** whether REAPER's scanned name for the plugin exactly matches `"VST3:" +
   vstPluginName()` (a display-name/scan-name mismatch would make `TrackFX_AddByName` return −1).
2. **The gesture reaching `performInstrumentDrop`:** whether `decideGesture` actually resolves
   `InstrumentDrop` over an FX button (vs. falling through to `OsDrag`, which produces exactly the
   observed "drops as audio into arrange" symptom) — check the `overFxHotspot`/`GetThingFromPoint`
   path and the single-capture arming guard.
3. **The `vst_chunk` write / blob format:** whether `TrackFX_SetNamedConfigParm` returns true and
   the injected blob matches what `setState` accepts (a format drift would create-then-play-nothing
   rather than fail to instantiate).

**Routing:** flagged for **staff-engineer** as a bug diagnosis. No product fork; no redesign
gates on it, but it is a **functional prerequisite** for the drop-to-FX gesture to feel finished.
**Verify in DAW:** drag a capture onto a track's FX button → a ReaSampler 9000 instance appears on
that track already playing that capture (one Ctrl-Z removes it).

### Engineering prerequisite 2 — default window size for 1080p (routed to implementation)

**Symptom (Daniel):** the editor window is too small by default; assume a 1080p minimum screen.

**SDK sweep (done this pass — mechanism verified):** the VST3 editor size is set by the
`IPlugView`/`CPluginView` contract. `getSize()` returns the view's `rect` (set via `setRect` — the
default the host opens at); `checkSizeConstraint()` is where a minimum is enforced; `onSize()`
handles host resizes; `canResize()` already returns `kResultTrue`. **Verified in
`vendor/vst3sdk/public.sdk/source/common/pluginview.h`.** Today `ReaSamplerEditor`'s constructor
sets `ViewRect(0, 0, 560, 400)` — the undersized default. **The fix is a one-line default change**
(a larger initial `ViewRect` sized for the new three-band Sample face on a 1080p display) **plus
an optional `checkSizeConstraint` minimum** so the host cannot shrink the window below a usable
floor. **This is not a platform limitation — the mechanism exists and is trivial.** The exact
default dimensions are a build-time value to set against the Sample face's band heights (hero
waveform + control strip want materially more than 400px tall; a ~840×560 or larger default is the
starting point, tuned at build). **Routing:** flagged for **staff-engineer**; no product fork.
**Verify in DAW:** the editor opens at the new default on a 1080p screen showing the full Sample
face without scrolling, and cannot be resized below the constraint floor.

### Module architecture (preserve the pure/shell split)

- **Pure (new/extended):** `envelope_overlay` (AHDSR/Trigger params + frame-length → polyline in a
  rect; unit-tested); `envelope_edit` (NEW — node hit-test + pixel-delta→clamped-param inverse map
  for the draggable envelope handles; mirror of `card_drag`; unit-tested at the monotonic/clamp
  boundaries); `velocity_curve` (NEW, r10 — bezier `eval(velocity 0–127)→amp 0–1` + control-point
  add/move/delete clamped to the 0–127×0–1 box, x-ordered, hit-test + pixel-delta inverse map;
  mirror of `envelope_edit`; unit-tested at eval + clamp/order boundaries); `keyboard_strip`
  extended with the natural/accidental predicate; the sampler core extended with the **key-track
  scalar** in the repitch math (unit-tested against known note/root/keyTrack → ratio) **and the
  velocity-curve eval at `Voice::start()`** replacing the linear `velocity/127` (r10; off the
  per-frame path); `sample_map` (`PerformanceZone`) extended with the additive `keyTrack` field
  **and the additive `velocityCurve` field** (r10 — both on the zones-payload version axis; the
  velocity-curve field sequences AFTER T-KEYTRK's `keyTrack` bump), plus the **envelope-v6
  `previewVelocity` field on `ComponentState`** (S-VIEW-F1) — all additive, version bumped,
  back-compat defaults on read.
- **Shell (`reasampler_editor.cpp`):** re-partition the paint/hit-test into the three views
  (Sample face, Browse modal overlay, Zone surface) replacing the two-view toggle; add the
  preview-trigger button + velocity knob wired to an off-audio-thread preview note through the
  voice engine (the velocity **persists** via the envelope-v6 `ComponentState` field, S-VIEW-F1);
  draw the envelope overlay + **its draggable node handles** (routing mouse events through the pure
  `envelope_edit` module and committing params via the same off-thread path the sliders use,
  S-VIEW-F2) + piano-key overlay via the kit; set the larger default `ViewRect` +
  `checkSizeConstraint`. All layout/hit-test math stays in the pure geometry modules.

### Precision / invariant implications

- **Read-only bank consumer (unchanged).** Key-tracking, preview velocity, envelope-node edits, and
  every marker/mode control are the instrument's **performance map / editor state** (D-B) — held in
  the instrument's own VST3 component state, **never written to `Sample` or the bank.**
- **Additive, back-compat component state.** Three additive fields land: `keyTrack` (per-
  `PerformanceZone`, default 100%) and — sequenced after it on the same zones-payload axis —
  `velocityCurve` (per-`PerformanceZone`, r10, default the R10-F1 curve) inside the zones payload,
  and `previewVelocity` (per-instance) as a new top-level `ComponentState` field via an **envelope
  bump to v6** on the independent envelope axis. Every older blob lifts on read (absent `keyTrack` →
  100%, absent `velocityCurve` → the R10-F1 default curve, absent `previewVelocity` → the chosen mid
  default), so already-saved instances restore cleanly. **Playback back-compat carries a caveat for
  the velocity curve:** under R10-F1 Option A (flat y=1 default) an already-saved zone with no
  stored curve now plays every velocity at unity — **not** bit-identical to the linear `velocity/127`
  it played before; under Option B (linear default) it is bit-identical. This is the one non-back-
  compat surface in S-VIEW and is the substance of fork R10-F1. `keyTrack` and `previewVelocity`
  remain fully bit-identical on lift. No existing field changes.
- **RT discipline (unchanged).** The preview-trigger fires a note through the existing voice engine
  via the off-audio-thread commit path (`commitMapAndReload` idiom); no new `process`-thread work,
  no allocation on the audio thread.
- **VST3 class UID unchanged.** A view reorganization + additive param is **not** a compat event;
  saved instances rebind and restore. The UID stays the S-NAME-1/S18 forever-locked identity.
- **Capture ≠ placement ≠ playback (unchanged).** The preview-trigger plays; it never captures,
  never inserts a timeline item. The three acts stay distinct.

### Open questions / forks (Daniel / Phase S team)

*(S-VIEW-F1 and S-VIEW-F2 are SETTLED — Daniel 2026-07-27 — and folded into the spec above:
preview velocity **persists** via envelope-v6 `ComponentState`; the envelope overlay's nodes are
**draggable** via the pure `envelope_edit` module. They are no longer open questions.)*

- **S-VIEW-F3 — Browse modal presentation.** Full-window overlay (lean — the modal-picker feel) vs.
  a large centered sheet with a dimmed Sample behind. Presentation detail; either satisfies "easy
  to summon and dismiss." **Still Daniel's call.**
- **R10-F1 — velocity-curve default (Daniel, 2026-07-27).** **Option A: flat y=1** (Daniel's
  verbatim default — every velocity plays at full level; NOT back-compat with today's linear
  `velocity/127`, so existing zones' dynamics change) vs. **Option B: linear y = x/127** (bit-
  identical to the shipped engine; contradicts the verbatim "full level" default). **Lean A**,
  flagged as a deliberate shipped-behavior change, not a silent regression. This is the only
  non-back-compat surface the velocity-curve feature introduces. **Daniel's call.**

### Must-verify before build (S-VIEW)

- **Editor size mechanism** — `getSize`/`setRect`/`checkSizeConstraint`/`onSize`/`canResize`
  **verified present** in `vendor/vst3sdk/public.sdk/source/common/pluginview.h`; confirm the exact
  min-size enforcement point (`checkSizeConstraint`) behaves under REAPER's host at build.
- **Drop-to-FX** — `TrackFX_AddByName` / `TrackFX_SetNamedConfigParm` / `GetThingFromPoint` all
  **verified present** in `reaper_plugin_functions.h`; the remaining work is **runtime diagnosis**
  (name match, gesture routing, blob format), not an SDK question.
- **Preview note through the voice engine off-thread** — confirm the existing `commitMapAndReload`
  / off-thread reload idiom is the right seam to fire a one-shot preview note without touching
  `process` on the UI thread; no torn state on the atomic voice-engine pointer.
- **Preview-velocity persistence seam (S-VIEW-F1)** — the envelope-v6 `ComponentState` field is the
  prescribed seam (verified against `src/vst/sample_map.h` + `reasampler_processor.cpp`
  getState/setState this pass); confirm at build that appending a `float previewVelocity` after the
  assignment marker keeps the v5→v6 lift clean and the zones-payload byte offsets unchanged.
- **Envelope node-edit inverse map (S-VIEW-F2)** — confirm the pure `envelope_edit` pixel→param
  inverse map produces params the sliders' own min/max already permit (so the two surfaces can
  never diverge), and that the monotonic time-node constraint holds at the segment boundaries.
- **Envelope-overlay time base** — confirm the seconds→frames resolution the overlay draws against
  matches the voice engine's live-rate resolution so the drawn shape lines up with the waveform.
- **Velocity-curve application point (r10)** — confirm `Voice::start()` (`sampler_core.cpp:252`) is
  the sole velocity→gain site and that replacing `velocityGain_ = velocity / 127.0` (line 261) with
  `velocityGain_ = curve.eval(velocity)` keeps the eval at note-on only, off the per-frame render
  path (line 408 `gain = amp * velocityGain_` unchanged). No new `process`-thread work or allocation.
- **Velocity-curve payload sequencing (r10)** — confirm the additive `velocityCurve` field on
  `PerformanceZone` lands as a zones-payload bump AFTER T-KEYTRK's `keyTrack` bump (not simultaneous),
  so the two sequential extensions of the same record never collide on one payload version number.
- **Velocity-curve eval monotonicity (r10)** — confirm the pure `velocity_curve` bezier is monotonic
  in x over the 0–127×0–1 box (one output per velocity) and that control-point edits stay clamped +
  x-ordered at the boundaries.

---

# Look-and-feel — visual design language (Phase L)

> **New pillar, own lettered phase, taken up by a parallel team.** Phase L is the
> whole-system look-and-feel effort — a shared LICE drawing kit and the surfaces that
> adopt it — that replaces the flat "temple os" drawing (opaque `LICE_FillRect` blocks +
> raw GDI `DrawTextA`) with a modern, sleek 2026 dark synth look across ReaSampler
> (the extension's docked bank panel) and ReaSampler 9000 (the Phase S VST editor + embed
> strip). Namespaced **`L` (Look-and-feel)**, orthogonal to and ungated by the
> M/D/B/R/V/S pillars. Product framing, the settled decision record (DS-1/DS-2/DS-3 all
> SETTLED 2026-07-26), palette, the three visual directions, and the toolkit assessment:
> `docs/product/visual-design-language.md`. Same standing discipline: **verify every LICE/
> WDL/SWELL API name/signature against `vendor/WDL` before use.** When a point lands,
> doc-keeper moves it to `COMPLETED.md`.

## What it is

A **shared LICE-based drawing kit** (palette + type scale + component-draw layer) and the
surfaces that consume it. The kit is the **one source of drawing** for the whole system —
a button, row, slider, or waveform looks identical in the bank panel, the embed strip, and
the VST editor because it is the same kit function (the one-source-multiple-views instinct,
applied to drawing). The current "temple os" look is the system drawing at the **floor** of
LICE (flat fills, GDI text, no gradients/AA/rounded/hover); the kit lifts every surface to
LICE's actual ceiling — which REAPER's own themed UI and SWS prove is a modern dark UI.

## Settled decisions (Daniel, 2026-07-26 — reasoning in `docs/product/visual-design-language.md` §6)

- **DS-1 — toolkit: LICE + WDL free game, no external frameworks.** Draw with **LICE**
  directly (gradients via `LICE_GradRect`, AA rounded via `LICE_RoundRect`/`LICE_Line`,
  cached AA text via `LICE_CachedFont`). **Reuse any useful WDL/vwnd piece** — skin/image
  helpers, draw idioms, a specific control (e.g. `virtwnd-listbox` for a long scroll list)
  — where it beats re-deriving; "don't reinvent the wheel." **Reject external frameworks**
  (iPlug2 / JUCE / VSTGUI — they re-open the settled bare-SDK+LICE build shape to solve a
  look problem that is not a toolkit-ceiling problem). **Caution, not a ban:** keep
  hit-test **geometry** in pure CTest-covered modules — do not import vwnd's retained-mode
  object model wholesale (its controls own their hit-test internally, which would move
  geometry into untestable shell code and undercut the pure/shell split).
- **DS-2 — visual direction: Direction B ("Neon Console") + Direction C's spectral
  keyboard strip. SETTLED 2026-07-26, REVISED 2026-07-26 (Daniel) — three-accent pastel
  accents AND REAPER-grey neutrals (palette-only).** *Neutral surfaces (revised):* the
  neutral ladder moved **from near-black up into REAPER's mid-grey theme family** so the
  dock reads as *part of REAPER, not a black slab* — `bg/base` ≈ `#2b2b2b` (REAPER chrome),
  `bg/panel` ≈ `#333333`, `bg/cell` ≈ `#3a3a3a` (REAPER track bg), `line/hairline` ≈
  `#4a4a4a`, `text/primary` ≈ `#dcdcdc`, `text/dim` ≈ `~#a0a0a0`+. Elevation-ladder
  discipline unchanged (base < panel < cell by a few %, micro-gradient + inner
  highlight/shadow carry elevation, not hard borders). *Accent layer (revised):* changed
  from **one electric cyan** to **three pastel accents**: `accent/primary` = **pastel lime
  green** (`~176,224,152`, the
  live/active/selected signal), `accent/secondary` = **pastel teal** (`~132,214,208`,
  categorical role A), `accent/tertiary` = **pastel purple** (`~194,170,232`, categorical
  role B); `accent/hot` is a lighter pastel-lime tint (`~200,236,178`). These are *starting*
  values — the implementer locks final hex against the theme module's WCAG tests, staying
  **within the pastel intent** ("soft-side" floor: the most pastel value that still clears,
  not re-saturated toward neon). The **spectral (hue-mapped) keyboard strip** — signature
  surface, glow-on-active as a static drawn state — is now a **pastel** sweep anchored on the
  three accents (pastel-lime low → pastel-teal mid → pastel-purple high). **A stylish/bundled
  font upgrade was considered and DECLINED (Daniel, 2026-07-26)** — no font bundling or
  redistribution; the kit keeps its **current** cached-font face and no new typeface is
  specified (§3.1). The kit palette stays **abstract** (role→color, one constants block —
  now three accent roles), so the direction is a single-file change. *Tight pairs flagged for
  the implementer (re-verify against the GREY ladder, not near-black):* **`text/dim` on
  grey** (mid-grey-on-mid-grey — lands ~4:1 at start, must be lifted toward `~#a8a8a8`+ to
  clear AA 4.5:1 body on `#333`/`#3a3a3a`, the classic floor failure); **the three pastels
  as state indicators / active fills on `bg/cell`** (the pastel cushion shrank from ~15:1 on
  near-black to ~6:1–7:1 on grey — still clears 3:1 but must be re-checked; if any drops
  below floor, nudge that hue slightly deeper within the pastel intent); body text **on a
  pastel fill** (light-grey on a light pastel can drop below AA 4.5:1 — use dark labels on
  pastel fills or darken the fill); and **secondary vs. tertiary** distinguishability (both
  cool/desaturated — verify they read as distinct categories). Full reasoning:
  `docs/product/visual-design-language.md` §2.1 + §4 + §6.
- **DS-3 — dock-panel scope: a thorough layout redesign, not a light re-skin.** L2 lays out
  the *full* button/affordance inventory — including M11's action-trigger buttons +
  keybinding-help labels — intuitively, uncluttered, and useful, then applies the kit.
  Sequenced after M11 merges so it designs against the actual landed button set.

## Palette + the "punch" rule (Daniel's standing taste)

Dark, modern, **visual punch over conservative contrast — now pastel, on REAPER-grey.** The
palette is defined by **role**, not hardcoded hue: `bg/base`, `bg/panel`, `bg/cell`,
`line/hairline`, `text/primary`, `text/dim`, `accent/primary`, `accent/secondary`,
`accent/tertiary`, `accent/hot`, `warn`. **Neutral surfaces (DS-2 revised, 2026-07-26):**
the neutral ladder sits in **REAPER's mid-grey theme family**, not near-black, so the dock
reads as *part of REAPER* — `bg/base` ≈ `#2b2b2b`, `bg/panel` ≈ `#333333`, `bg/cell` ≈
`#3a3a3a`, `line/hairline` ≈ `#4a4a4a`, `text/primary` ≈ `#dcdcdc`, `text/dim` ≈ `~#a0a0a0`+.
A modern dark UI is built from **elevation layers**, not borders — surfaces gain a
**micro-gradient** (`LICE_GradRect`, a few percent lighter at the top) + a 1px inner
top-highlight / bottom-shadow (the vwnd trick) instead of flat fills; the grey ladder keeps
this discipline (base < panel < cell by a few %, subtle steps like REAPER, not hard
outlines). The three accents carry categorical meaning: **primary (pastel lime) =
live/active/selected**, **secondary (pastel teal) + tertiary (pastel purple) = supporting
categorical distinctions** (kinds, not intensity — primary is always "what's live now").
**WCAG-floor discipline (pastel + grey re-read):** moving neutrals *up* into grey makes two
pairs *harder* — (1) **`text/dim` on `bg/panel`/`bg/cell`** is the classic
mid-grey-on-mid-grey floor failure (must be lifted light enough to clear AA 4.5:1 body on
the greyest surface it draws on), and (2) **the three pastels as state indicators on grey**
have a shrunken contrast cushion (~6:1–7:1 vs. ~15:1 on near-black — still clear 3:1 but
re-verify; nudge a hue slightly deeper within the pastel intent if it drops below floor).
Text-on-a-pastel-fill (AA 4.5:1) remains a tight pair. Take the *most pastel* value that
still clears the floor **on grey** — approached from the **soft side**, never re-saturated
toward neon "to be safe," except where the grey floor forces a small deepening. The `warn`
role (red/amber) is reserved *only* for byte-deleting or clip states (prune, delete).
Because the palette is role-based in one constants block, the settled-and-revised
B+pastel+grey+spectral direction (DS-2) is one file.

## "Speed is the selling point" — a design constraint, not a tagline

The UI must **feel instant, and no decoration may cost that.** Sub-frame hover/press/drag
feedback repainted immediately on the input message (instant acknowledgment *is* the
perception of speed); zero-jank via the preserved double-buffer discipline (draw to
`LICE_SysBitmap`, single `BitBlt`); region-scoped `InvalidateRect` during a drag
(build-time residual). **No decorative animation** — no tweens/fades/pulses; the only
permitted motion is a level/meter readout following the audio directly (as the embed strip
already does). Direction C's glow/bloom is a **static drawn state, never a pulse.** The
"fast" feeling is typography + hover + no-jank, not motion.

## Kit architecture (the pure/shell split)

Pure (no LICE, no REAPER types, unit-tested — the mirror of `mode_switch`/`bank_grid`):
- **`theme`/palette module** — role→color mapping, direction-selectable via one constants
  block (the B + REAPER-grey-neutral + three-accent-pastel + pastel-spectral values). Pure;
  unit-tested that each text-on-surface pair clears its WCAG floor (the "punch" rule made
  testable). *DS-2 revision (2026-07-26):* (a) the neutral ladder moves from near-black up
  into REAPER's mid-grey family (`bg/base` `#2b2b2b` / `bg/panel` `#333333` / `bg/cell`
  `#3a3a3a` / `line/hairline` `#4a4a4a` / `text/primary` `#dcdcdc` / `text/dim` `~#a0a0a0`+),
  and (b) the accent role expands from one to three (`accent/primary` lime, `accent/secondary`
  teal, `accent/tertiary` purple) with the spectral ramp a pastel sweep anchored on those
  three — all still confined to the one constants block + the shell's font lifecycle. **The
  WCAG test must be re-run against the GREY ladder** — it gains the newly-tight grey pairs:
  `text/dim`-on-grey (AA 4.5:1) and each pastel-as-state-indicator on `bg/cell` (3:1), plus
  the existing text-on-pastel-fill pairs. (No font change: a bundled-font upgrade was
  declined — the cached-font set keeps the kit's current face.)
- **Component geometry/hit-test helpers** — button rect, slider track/handle geometry,
  list-row rect + hover hit-test, and any new layout module L2 needs (an action-bar layout
  module). No LICE, no host types; CTest-covered. Existing pure modules
  (`bank_grid`/`tab_strip`/`mode_switch`) stay the source of truth for what they own.

Shell (LICE-facing, DAW-verified — thin draw layer):
- **Draw kit** — `fillSurface` (micro-gradient + inner highlight/shadow),
  `drawButton`/`drawSlider`/`drawListRow`/`drawWaveform`/segmented-switch/tab draw, and a
  shared `text()` over a cached `LICE_CachedFont` set (title/label/value-mono/micro). Owns
  the cached-font lifecycle. Honors the interaction state model (rest/hover/active/pressed/
  dragging/focus/disabled). **DS-1: WDL/vwnd reuse is assessed here at build time** — reuse
  a vwnd piece where genuinely cheaper, else draw on LICE; hit-test geometry stays pure
  regardless.

## L2 dock-panel layout contract (the M11-aware inventory)

DS-3 makes L2 a **layout design**, not a skin pass, because M11 adds a real button
inventory. L2 must place **every** affordance below without crowding the grid (the
centerpiece), grouping by *task*:

**Existing (landed / specced):**
- Bank **grid** — thumbnails, multi-select, keyboard nav, audition, focus ring.
- Design View **segmented mode switch** (`[ Arrange | Design ]`) + per-mode membership count.
- Multi-bank: **named-banks tab strip** (LICE-drawn, overflow/scroll), **active-bank
  indicator**, **pool/banks full-height toggles**, create/rename/delete/activate-bank
  affordances, per-selection **move / copy / remove** sample menu.
- **Prune** button (R-E) — the byte-deleting action; `warn`-colored, set apart.

**M11 adds (dev PLAN.md §M11 — merging to dev now):**
- **Action-trigger buttons** — clickable buttons firing the capture + provenance action
  family directly (capture item / capture track scopes, re-capture from source,
  resample-and-mute-source, batch capture, conform-on-insert, insert-at-cursor, drag-out,
  null-test verify). A cluster.
- **Keybinding-help labels** — each capture/provenance action surfaces its current key
  binding (e.g. "Capture Item → F5") or an "unbound"/"—" marker.

**Layout mandate:** group by task (capture / organize / reclaim / view), not by phase; a
compact action bar/toolbar for the frequent capture actions (icon+label, keybinding as a
`micro` sub-label), an overflow/menu for the rare ones, header space for the mode switch +
active-bank indicator, the bank tab strip + move/copy/remove organize cluster together,
prune set apart and `warn`-marked. Density is a design decision — 8px grid, elevation
layers over hairlines, hover on every interactive element. Then apply the L1 kit to draw
it. New layout math goes in a pure geometry module; `bank_grid`/`tab_strip`/`mode_switch`
stay the pure source of truth for their own hit-testing. **L2 sequences after M11 merges**
so it inventories the actual landed buttons.

## L4 dock-panel button layout (top / bottom / footer re-home)

L4 is a **second layout pass over the same `bank_panel`** that re-homes the L2 button inventory
around *frequency and intent*. It ships **no new action and changes no capture/placement
behavior** — every button fires an existing registered action; the "capture ≠ placement"
load-bearing principle is untouched (the buttons only *fire* the split acts, they never fuse
them). It is **ungated by Phase S** (the dock panel is on dev) and independent of L3.
**L4 sequences AFTER the in-flight DS-2 palette-revision branch merges to dev** — both rework
`bank_panel` heavily, and landing L4 concurrently would collide the same file. Drawn through the
L1 kit in the DS-2 grey-neutral + three-accent-pastel palette (prune stays `warn`); no palette
or font decisions are re-opened here.

**The new three-zone structure:**

- **Top toolbar = capture + placement + maintenance.** The capture cluster (capture item,
  capture track, batch items, batch razor, capture RT), the placement cluster (insert,
  insert-conform), and the maintenance cluster (re-capture, cancel-realtime) move from the
  bottom L2 action bar to a **top** toolbar — the eye's first landing, matching the acts the
  tool exists for. Icon+label buttons with the keybinding as a `micro` sub-label (the M11
  keybinding-help convention), drawn through the kit.
- **Bottom toolbar = Design View tagging + switching.** The space the capture/placement buttons
  vacate holds the **Design View action family** as buttons: **tag / untag selected tracks for a
  mode, activate Arrange, activate Design, toggle active mode, show-both.** These are the
  registered Design View actions today; L4 gives them a button home here.
- **Footer = narrow Arrange|Design toggle · Tail button · … · Prune.** The large top
  `[ Arrange | Design ]` segmented toggle **shrinks to just-wide-enough-for-its-text** and moves
  into the footer at the **left**, carrying its per-mode membership count as a compact adjacent
  label. The **Tail** affordance is converted from a **click-zone to a proper kit button**
  (rest/hover/pressed states; click still cycles the tail setting). **Prune** stays the
  byte-deleting action — **set apart at the far right, `warn`-colored** — the only file-deleting
  affordance, kept isolated so no benign toggle sits next to it.

**Footer affordance order (left → right):** `[Arrange|Design]` toggle · Tail button · … ·
**Prune** (rightmost, set apart, `warn`). The order reads benign/frequent at the left
(view-mode toggle, tail-length control — both "how this panel/capture behaves") → destructive/
rare at the right (Prune, isolated), so a mis-click near the left is cheap and the one
destructive control is spatially and chromatically distinct.

**Pure/shell discipline (unchanged).** All new toolbar-row and footer-strip layout math goes in
pure CTest-covered geometry modules — **extend/mirror `action_bar`** (the toolbar row layout,
now instantiated top and bottom) and **`mode_switch`** (the now-narrow, fit-to-text footer
toggle geometry + hit-test). `bank_grid` and `tab_strip` remain the pure owners of their own
surfaces' hit-testing. The L1 kit draws; the geometry stays pure. **L4 resolves its re-home
against the post-palette-revision `bank_panel`** (build-time inventory once that branch is on
dev).

## L5 dock-panel button refinements (overflow menu · faces+tooltips · opposite-mode tags · Toggle removal · grouping)

L5 is a **third refinement pass over the same `bank_panel` toolbars L4 built** — it re-homes
and re-labels buttons for legibility; it **ships no new action and changes no capture/placement
behavior** (every button fires an existing registered action; "capture ≠ placement" is
untouched — the buttons only *fire* the split acts). **Ungated by Phase S** (the dock panel is
on dev) and **independent of the L3 gate.** Drawn through the L1 kit in the DS-2 grey-neutral +
three-accent-pastel palette (prune stays `warn`); **no palette or font decision is re-opened.**
L5 sequences AFTER L4 (both rework the same `bank_panel` toolbars).

**1. Top-toolbar overflow menu.** The less-frequent capture variants — **Batch Items, Batch
Razor, Capture RT, Cancel RT** — leave the visible top bar for a **right-anchored "⋯ / More"
menu button** (kit-drawn button; on click a `TrackPopupMenu` popup lists all four, each entry
firing its existing command id via `Main_OnCommand`). The frequent acts stay on the bar in
cluster order Capture → Maintenance → Placement: Capture Item, Capture Track (Capture);
Re-capture (Maintenance); Insert, Insert Conform (Placement). The menu button's rect +
hit-test is **pure** (extend/mirror `action_bar` / `prune_button`); the popup + dispatch is
shell. The menu entries reuse the same `resolveBarCommandId` path the bar buttons use, so a
keybinding and a menu pick fire identically.

**2. Short faces + full-name tooltips (drop the `ReaSampler:` prefix).** Button *faces* carry
the terse `ActionBarRow.shortLabel` (already the case since L4) — L5 formalizes the face set and
adds a **hover tooltip showing the FULL action name with the `ReaSampler:` display prefix
stripped.** Note the prefix (`actionDisplayPrefix()`) is baked into the *registered gaccel
action name*, not the button face — so the tooltip derives the full name and strips the prefix
for display; the button face never carried the prefix. The keybinding is surfaced in the
hover tooltip ("`phrase — binding`" when bound, bare phrase when unbound) via
`kbd_getTextFromCmd`; the `micro` sub-row is removed from `ActionBarSlot`. **Tooltip mechanism: custom LICE-kit hover-delay tooltip** (`tooltip` pure
module) — owns its own hover timer + LICE overlay draw, stays inside the L1 kit (DS-1 "keep
drawing in the kit"). Sourced from the registered action phrase (not `kbd_getTextFromCmd`);
`ReaSampler:` prefix stripped at draw time; tooltip box width clamped to the client so it never
overhangs a narrow dock. The hover-timer threshold + overlay draw are the only DAW-bound pieces;
the "which button, what text" decision stays pure.

**3. Bottom-toolbar Item/Track × Arrange/Design tag buttons, opposite-mode-only.** The current
`Tag Design` / `Untag` pair is replaced by **four buttons — "Item: Arrange", "Item: Design",
"Track: Arrange", "Track: Design"** — in the Tagging cluster. **Both action families already
exist in the model + actions layer** (research-confirmed): the *track* family is
`doTag`/`doUntag` on the track selection (`VIEW_TAG_DESIGN` and `VIEW_TAG_ARRANGE`, where
Tag→Arrange == untag); the *item* family is `doMoveItems` on the item selection
(`VIEW_MOVE_ITEMS_DESIGN` / `VIEW_MOVE_ITEMS_ARRANGE`, driving `planItemRetag` + lane minting).
**So the four buttons are layout + enablement wiring over existing actions — NOT new feature
work; no `view_mode_model` / `view` / `actions` change is required.**

**Enablement rule (precise).** Let `active` = `view().activeModeId()` (the SAME read the footer
`[Arrange|Design]` toggle uses — one source of truth). A tag button's *target mode* is the mode
in its label (Arrange or Design). A button is **live iff target ≠ active**; otherwise it is
drawn `Disabled` (kit `InteractionState::Disabled`, `Role::TextDim`) and its click is a no-op.
Concretely:
- **Design active** → `Item: Arrange` and `Track: Arrange` are live (they send the selection to
  Arrange); `Item: Design` and `Track: Design` are disabled (the selection is already there).
- **Arrange active** → the reverse: the `…: Design` buttons are live; the `…: Arrange` buttons
  are disabled.
The disabled predicate is **pure** (active mode → per-button live/disabled, unit-tested); the
shell reads the active mode once per draw and applies it. Item buttons act on the current media-
item selection; Track buttons act on the current track selection — matching the existing action
bodies exactly (no selection semantics change).

**4. Toggle button removed.** `VIEW_TOGGLE_MODE` leaves the bottom toolbar — the footer's
`[Arrange|Design]` toggle (L4) already covers mode switching. **The action stays registered**
(keybinding-bound, FOREVER-STABLE id unchanged); only its *button home* is removed.

**Fate of Activate-Arrange / Activate-Design / Show-Both (FORK — RESOLVED, Daniel's call).**
- `VIEW_ACTIVATE_ARRANGE` / `VIEW_ACTIVATE_DESIGN` — **dropped from the bottom toolbar** (actions
  stay registered, FOREVER-STABLE ids unchanged). The footer `[Arrange|Design]` toggle is the
  single mode-switch affordance; the bottom bar is tagging + Show Both only.
- `VIEW_SHOW_BOTH` — **kept** as a set-apart button on the bottom toolbar. It is the cross-mode
  "pin visible in every mode" escape hatch and is not covered by the footer toggle.
- **Landed bottom-toolbar inventory:** `[ Item: Arrange | Item: Design ] · [ Track: Arrange |
  Track: Design ]  ⟩⟩  [ Show Both ]` — Tagging cluster (four opposite-mode buttons) set apart
  from a lone `Show Both`. No Toggle, no Activate-Arrange/Design (footer toggle owns switching).

**5. Semantic-grouping spacing.** L4's `kBarSpec` is `buttonGap=4` / `clusterGap=16` (4:1). With
the bottom bar's cluster boundary now more meaningful (four tag buttons vs. the `Show Both`
remnant) the grouping should read at a glance — **widen the inter-cluster gap: start ≈
`clusterGap=24` / `buttonGap=4` (6:1), tuned in-DAW.** One `kBarSpec` still serves both
toolbars (identical button shape top and bottom); only the gap ratio changes.

**Pure/shell discipline (unchanged).** All new geometry — the top-bar overflow menu-button rect
+ hit-test, the opposite-mode enablement predicate, the wider grouping spacing — goes in pure
CTest-covered modules (extend/mirror `action_bar` / `prune_button`; add a pure enablement
predicate). The `TrackPopupMenu` popup, the `Main_OnCommand` dispatch, and the tooltip hover-
timer + LICE overlay draw are the only DAW-bound pieces; the L1 kit draws. **L5 resolves its
menu/tooltip/action-id specifics against the landed L4 `bank_panel`** (build-time confirmation).

## L7 capture ordering · card metadata · selection styling (ordering model · overlay · tertiary-border selection)

L7 is the **next ungated dock-panel pass over the same `bank_panel` grid** L4–L6 built. Unlike
L4/L5 (pure layout over existing actions), **L7 (1) is a persisted-model change** — it adds an
explicit per-sample display order with gap-preserving sparse placement to the persisted bank
state and its JSON round-trip. (2) and (3) are draw-only. **No new capture/placement behavior;**
the "capture ≠ placement" principle is untouched. Drawn through the L1 kit in the DS-2
grey-neutral + three-accent-pastel palette; **no palette/font decision is re-opened.** Ungated
by Phase S; independent of the L3 gate. Sequences AFTER L6.

**Research baseline (dev, confirmed at spec time).** The grid draws in **`BankIndex` insertion
order**: `BankIndex` holds `std::vector<Sample> samples_` in insertion order (`all()`), and
`bank_grid::computeCellRects` tiles exactly one contiguous rect per sample left-to-right,
top-to-bottom — **no explicit ordinal, no sparse slots today.** `Sample` already carries
`lengthSeconds`, `lengthBeats`, and `captureTempo` (BPM at capture) but **no time-signature
field.** The selected cell is drawn (`bank_panel::drawThumbnail`) as
`InteractionState::Active` (accent-fill surface) + an `accent/primary` border + an *inverted*
waveform (`bg/base`), with focus as a `text/primary` double-line ring. Drag today: press on a
selected cell arms a drag; crossing a threshold begins it; leaving the client rect hands off to
OS drag-out (`drag_out::decideGesture`); a drop on a tab / the other region moves (or copies on
Ctrl) the samples to that bank. **There is no same-bank in-grid reorder today.**

### 1. Persisted deterministic order + sparse (gap-preserving) placement

Each bank (and the pool) carries an **explicit, persisted display position per sample** — the
grid no longer derives order from insertion order. Positions are **gap-preserving**: a sample
may occupy a slot that leaves earlier slots empty (an empty first row above an occupied second
row is a valid, persisted state).

**Where the position data lives (the CLAUDE.md constraint).** `bank_model` / `Sample` are stated
**untouched by `bank_book`'s wrapping** (no `bankId` on `Sample`; a bank is a logical grouping
over the shared pool). Display position is a **per-bank display concern**, so it belongs with the
bank's membership, **not on `Sample`** — a copy of a sample into two banks can sit at different
slots. **Recommended carrier: a per-`Bank` ordered position map in `bank_book`** (sample id →
slot), leaving `bank_model` untouched. The exact carrier (ordered id list with gaps vs. explicit
id→slot map) is settled at build; the *contract* below holds either way.

**Ordering contract (deterministic, gap-preserving):**
- **Deterministic:** the grid iterates positions in ascending slot order; ties are impossible
  (one sample per slot). The order is fully determined by the persisted position data, not by
  insertion order or hash.
- **Insert (new capture):** a new capture takes the **next free slot after the last occupied
  slot** (append). It never fills an earlier gap automatically — a gap is a user's deliberate
  layout, not a hole to be plugged.
- **Delete / remove / prune:** removing a sample **leaves its slot empty** (does not re-pack) so
  every other sample keeps its position. Trailing empty tail is trimmed for scroll-extent
  purposes; interior gaps are preserved (confirmed at build).
- **Reorder:** the user drags a card to a target slot within its bank; the pure reorder mutator
  moves that sample's position to the target slot, gap-preserving. **Drop-into-empty-slot places
  there; drop-onto-occupied inserts-before and shifts the tail** (matching common file-manager
  reorder) — SETTLED as the *default* drop. (The **Alt+drop-onto-occupied = REPLACE** override is
  specified below under Drag disambiguation, F3.)

**JSON round-trip + migration (load-bearing).** `serialize`/`deserialize` stay lossless
including positions (`deserialize(serialize(x)) == x`). **A pre-L7 project blob has no position
data** → on load it defaults to **current insertion order, densely packed (no gaps)**, so a
project saved before L7 is visually identical on first post-L7 load. This default is the
migration; it is one-way (once re-saved, the position data is authoritative). Pure and
hard-tested — this is a persisted-model change, tested to the same bar as `bank_book`'s existing
round-trip + legacy migration.

**Undo.** A reorder is **one Ctrl-Z** — the actions/shell layer wraps the mutation in a batched
undo point (`Undo_BeginBlock2`/`EndBlock2`), matching every existing bank-verb's undo discipline.

**Position-model shape (F2 — SETTLED 2026-07-27: interchangeable substrate, NOT fixed slots).**
The carrier is the **plain gap-preserving interchangeable-slot substrate** — a per-bank id→slot
map (Daniel: *"I don't think I want fixed slots MPC style, but the substrate of interchangeable
slots is valuable"*). It is **not** M9-shaped: no slot *identities*, no numbered/addressable
slots that persist independent of their occupant, no slot actions, no MIDI-bindable slot numbers,
no capture-to-slot-N. A slot is just a display position a sample occupies; dragging cards
rearranges which sample sits where.

**M9 note.** M9 "slots" (capture-to-slot-N / insert-slot-N, MIDI-bindable, MPC-style) is
**abandoned (Daniel, 2026-07-27)** — will not be built. L7 adds **no** slot-numbered
capture/insert actions and **no** MIDI bindings. The "plain vs. M9-shaped" sub-fork is closed:
plain gap-preserving substrate, per F2 above.

### 2. Decorative metadata overlay (bars.beats · s.ms)

Each card overlays, on the waveform, two decorative read-outs of **capture length**:
- **bottom-LEFT:** length in **bars.beats.subdivisions** (musical).
- **bottom-RIGHT:** length in **seconds.milliseconds** (wall-clock).

**Contract:** decorative and **non-interactive** — no hit-test, no hover, no selection role.
Drawn via the L1 kit `text()` in the **micro / value-mono** type class, in `text/dim` (or a
subtle shadowed variant for legibility over the peaks), subordinate to the waveform. **Respects
the speed constraint (no animation).** Pure formatting helpers (below) are unit-tested; only the
kit draw is shell.

**Bars.beats source (F1 — SETTLED 2026-07-27: capture-time stamp).** bars.beats.subdivisions
requires a **tempo + time-signature** reference. `Sample` already carries `captureTempo` (BPM at
capture) and `lengthBeats`, but **no time-signature.** **Decision: capture-time stamp** — add
`captureTimeSigNum` / `captureTimeSigDenom` to `Sample` + its JSON round-trip, stamped on the
capture path (read the project meter at capture via `TimeMap_GetTimeSigAtTime` — confirmed
at build against the SDK). bars.beats.subdivisions renders
from the stamped tempo + meter, **stable under later project tempo/meter changes** — a bank sample
outlives the project state it was captured under, matching the existing `captureTempo` stamp
philosophy. This is a **capture-path write beyond draw work** (its own checkbox in the PLAN, now
settled). Old samples with no stamp fall back gracefully (blank musical read-out, or a documented
assumed 4/4).
- **(rejected) Live project meter at draw time:** the label would drift under the card as the
  project tempo/meter changes, and would be wrong for any sample captured under a different
  meter than the project's current one.

**Formatting helpers (pure, tested).** `bars.beats.subdivisions` from `lengthSeconds` +
`captureTempo` + capture-time signature; `seconds.milliseconds` from `lengthSeconds`.
Deterministic; graceful on edge cases (zero length → both read empty/`0`; missing tempo → blank
musical read-out, keep the s.ms read-out).

### 3. Selection styling — tertiary border replaces inversion

A selected card **drops the inverted accent-fill treatment** and instead draws the **normal
cell** (Rest or Hover surface) + an **`accent/tertiary` border** (pastel purple `#C2AAE8`); the
**waveform draws in its normal accent color** (the inverted `bg/base` wave is removed). The four
grid-card interaction states stay **visually distinct and coherent:**
- **Selected:** normal cell surface + **`accent/tertiary` (purple) 1px border**. No fill change,
  no wave inversion.
- **Focus (caret):** the existing distinct inner ring — keep it separate from the selection
  border so a focused *and* selected card reads both (e.g. purple outer border + a
  `text/primary` inner focus ring). Settle the exact inner treatment at build so focus is
  legible on top of the selection border.
- **Hover:** the kit Hover surface (unchanged) — a fill-state change, orthogonal to the purple
  border, so a hovered selected card still reads as selected.
- **Drag-target slot:** the reorder drop-target highlight (a distinct accent — recommend
  `accent/hot` outline on the target slot) must not be confusable with the purple selection
  border; spec the exact treatment at build.

### Drag disambiguation (F3 — SETTLED 2026-07-27)

In-grid reorder must coexist with the existing internal bank-move/copy drag and OS drag-out.
**Precedence (one clean rule, evaluated live during the drag):**
1. **Pointer leaves the client rect → OS drag-out** (unchanged; `drag_out::decideGesture` wins
   first — the existing invariant #4 boundary).
2. **Else drop lands on a tab / the OTHER region's bank → move/copy** (unchanged; Ctrl = copy).
3. **Else drop lands within the SAME bank's own grid → reorder-to-slot** (new).
   - Onto an **empty** slot → place there.
   - Onto an **occupied** slot, **no modifier** → insert-before-and-shift-tail (the default,
     above).
   - Onto an **occupied** slot, **Alt held** → **REPLACE** the occupant (below).

So: leave-client wins → else other-bank wins → else same-bank-grid = reorder. The precedence is
encoded in a **pure decision helper** (mirror `drag_out::decideGesture`); the shell reads the
live pointer + focused region + client rect + **modifier state (Alt)** and calls it. This keeps
the reorder gesture from ever stealing an intended bank-move or OS-drag, and keeps a same-bank
in-grid drag from being mis-read as a no-op (today a same-bank drop is a no-op; L7 gives it
reorder meaning).

**Alt+drop-onto-occupied = REPLACE (SETTLED 2026-07-27).** Holding **Alt** at drop over an
occupied slot **replaces the occupant** instead of inserting-and-shifting. Replace semantics,
precisely:
- The replaced sample is **removed from THAT bank's index only** — same semantics as the existing
  remove-from-bank verb (`BankBook::removeSample` index-only). **The file stays on disk;** the
  owned-manifest and Phase R prune govern its bytes. If the replaced sample's last reference
  disappears, that is exactly the existing cross-bank-reference story (`hashReferencedElsewhere`
  reports whether the hash is still referenced elsewhere; prune later reclaims a now-orphaned
  owned file). Replace introduces **no new deletion authority** — it never touches the disk.
- The dragged sample then takes the vacated slot (the slot's position is preserved; only its
  occupant changes).
- **Pool case (un-evacuable pool — SETTLED rule).** The pool's privileges (un-deletable,
  un-renamable, **un-evacuable**, never zero banks) are enforced in `bank_book`. **Alt+Replace is
  allowed in the pool only when it does not violate a pool privilege.** Concretely: replacing a
  pool entry is an index-only removal of that pool sample; it is **permitted** as long as it does
  not empty the pool below the pool's floor and does not remove the *last* reference in a way the
  pool's rules forbid. The consistent rule the model enforces: **the replace's index-removal step
  is the same operation as remove-from-bank, and it must pass the same pool-privilege guard that
  remove already applies — if remove-from-pool would be rejected for that sample, Alt+Replace over
  it is rejected too** (the drop falls back to a no-op; the pool-privilege guard is reused
  as-is — confirmed at build). No special pool-only replace path; one rule, guarded by the
  existing pool invariants.

**Drop-result cursor cues (SETTLED 2026-07-27 — REAPER-idiomatic special cursors).** During a
drag the cursor must indicate **what the drop will do**, following REAPER's idiomatic use of
distinct action cursors. The cue set:
- **Reorder-to-slot** (within the same bank's grid) — a move/reorder cursor.
- **Move/copy to another bank or tab** — the move (or copy, when Ctrl is held) cursor, matching
  the existing internal-drag semantics.
- **OS drag-out** (pointer left the client rect) — the OS copy/drag cursor (owned by the OS drag
  loop once handed off).
- **Replace** — a distinct replace cursor, shown **only when Alt is actually held over an
  occupied slot** (i.e. only when precedence resolves to the Alt+replace case). It must not appear
  over an empty slot or when Alt is not held.

**Shell mechanism:** the shell sets the cursor via Win32/SWELL `SetCursor`. SWELL stock cursors
were chosen at build (Reorder→`IDC_SIZEALL`, Move→`IDC_HAND`, Copy→`IDC_UPARROW`,
Replace→`IDC_SIZEWE`; no custom cursor load/synthesis required). **The *decision* of which cue
applies stays in the pure gesture/disambiguation helper** — the same helper that resolves
precedence returns the resolved gesture (reorder / move / copy / os-drag-out / replace), and the
shell maps that pure result to a cursor. No cue logic in the shell; the shell only owns the
`SetCursor` call and the cursor resources.

### Pure/shell discipline (L7)

Model: the position carrier + gap semantics + **reorder mutator + Alt-replace mutator** (the
latter reusing the existing index-only remove-from-bank semantics + pool-privilege guard) + JSON
round-trip/migration are **pure** (in `bank_book`, CTest-covered to the bar of its existing
round-trip). Layout: the sparse-aware slot↔rect math + point→slot hit-test + the
drag-disambiguation decision (**including the resolved-gesture result that drives the cursor cue,
and the Alt-over-occupied → replace resolution**) are **pure** (extend `bank_grid`; mirror
`mode_switch` / `drag_out::decideGesture`). Formatting: the bars.beats and s.ms formatters are
**pure**. Shell (DAW-bound): the reorder/replace drag wiring + drop-target highlight, the
**cursor `SetCursor` call mapping the pure resolved-gesture to a cursor resource**, the
capture-time-signature stamp (F1, settled) read on the capture path, and the kit
overlay/selection-border draw. The L1 kit draws; no palette/font decision re-opened.

## The L3 gate + Phase S coordination contract

**L3 (VST editor + embed-strip restyle) has landed (merged `c53683e`, 2026-07-27).** The
Phase S surfaces (`reasampler_editor.cpp` + `reasampler_embed.cpp`) arrived on dev drawing
flat `LICE_FillRect` blocks + raw GDI `DrawTextA`, off a local pre-L1 forest-green palette
— the coordination contract's "born in the kit" branch did not occur — so L3 performed a
full restyle of both draw shells through the L1 kit. **L1, L2, L3, L4, L5, L6, and L7
have all landed — see `COMPLETED.md`. Phase L is complete.** The Phase Q gate condition
"Phase S + L3 merged to dev" is now satisfied.

**Coordination contract (load-bearing — resolved):** Phase S's S10–S13 built their
interaction UX with the current drawing and adopted the L1 kit via L3 (the coordination
contract's "L3 restyles them" branch). There is now one kit and one look across both
artifacts; L3 completed the VST/embed adoption and applied the settled-and-revised **B +
three-accent pastel** treatment (with C's **pastel** spectral keyboard strip as the
signature surface), routing text through the kit's cached-font `text()` (§3.1 — the kit's
current face; no font change). The VST3 class UID is unchanged — a visual refresh is not a
compat event.

## Precision / invariant implications (what Phase L does NOT change)

- **The pure/shell split holds.** All layout/hit-test stays in pure CTest-covered geometry
  modules; the kit's *draw* half is shell, its *geometry* half is pure — even where a WDL
  piece is reused (DS-1). No hit-test math moves into untestable code.
- **RT discipline untouched.** The kit is draw-thread only; nothing here touches `process`
  or any off-thread reload handoff (a Phase S concern surfaced at L3).
- **Read-only-over-bank untouched.** This is look-and-feel; no data-ownership change.
- **The capture/placement load-bearing principle is untouched.** Phase L draws; it does not
  capture, place, or mutate the bank.
- **VST3 class UID / component-state contract unchanged** (Phase S concern; noted for L3).
- **Windows-only (D5).** Font/GDI/HFONT choices assume Windows; no cross-platform font
  fallback concern.

## LICE / WDL API surface (verify all signatures)

- **LICE design-kit surfaces (L1).** `LICE_GradRect`, `LICE_RoundRect`, AA
  `LICE_Line`/`LICE_FLine`/`LICE_ThickFLine`/`LICE_Circle`/`LICE_FillCircle`/
  `LICE_DrawCBezier`, `LICE_FillTriangle`/`FillTrapezoid`/`FillConvexPolygon`, and the
  `LICE_CachedFont`/`LICE_IFont` font engine (`SetFromHFont`, AA `DrawText`, shadow/outline/
  glow FX flags). Verified *present* in `vendor/WDL/WDL/lice/lice.h` + `lice_text.h`;
  **confirm exact signatures + the `LICE_CachedFont`↔`HFONT` lifecycle at build.**
- **WDL/vwnd reuse (DS-1, build-time assessment).** `virtwnd-slider.cpp` /
  `vwnd_slider_drawknobstack` (slider/knob drawing reference), `virtwnd-listbox.cpp`
  (candidate scroll listbox), `virtwnd-controls.h` (`WDL_STYLE_*` gradient hooks),
  `virtwnd-skin.h` (image-skin helpers) — all in `vendor/WDL/WDL/wingui/`. Reuse where a
  piece beats re-deriving; keep hit-test geometry pure regardless.
- **Panel drawing/hit-test (L2).** Reuses the `bank_panel` LICE surface + the existing pure
  `bank_grid`/`tab_strip`/`mode_switch` hit-test modules; the new action-bar layout is a new
  pure module. `WM_MOUSEMOVE`/`TrackMouseEvent` (`WM_MOUSELEAVE`) for hover on the panel's
  existing timer-driven `wndProc`. Verify against the SWELL headers as `bank_panel` already
  does.

## Non-goals / guardrails

- **No external UI framework.** iPlug2 / JUCE / VSTGUI are rejected (DS-1). LICE + reused
  WDL pieces are the toolkit; reject any path that pulls in a new framework.
- **No hit-test geometry in untestable shell code.** Even when reusing a WDL piece, layout/
  hit-test math stays in pure CTest-covered modules (DS-1 caution). Reject a control whose
  adoption would move geometry into the shell without a pure test seam.
- **L2 does not restructure the panel bones.** The vertical-split / grid / tab structure is
  sound and stays; L2 designs the *layout of the button inventory around it* (DS-3). A
  ground-up structural rework of landed Phase-B panel structure is out of scope.
- **L3 does not build on dev until Phase S lands there.** The gate is explicit; do not chase
  Phase S files on dev.
- **A visual refresh is not a compat event.** VST3 class UID, command-id strings, ext-state
  namespaces, and component-state contracts are unchanged by Phase L.
- **Verify LICE/WDL/SWELL surfaces** against `vendor/WDL` before use.

---

# Structural reorganization — reorg spec (Phase Q — Quality)

> **New pillar, own lettered phase, and the LAST structural pillar.** Phase Q is a **pure
> structural refactor** of `src/` — more encapsulation, granular namespaces, `core/`/`shell/`/
> `app/` subdirectories — against a stated quality bar (*"mtytel Vital is my code reference for
> quality"*), to bring the codebase "into the realm of something I can stand to look at." It
> **ships no feature and changes no behavior**: the test suite passing unchanged is the proof of
> correctness. Namespaced **`Q` (Quality)** — M/D/B/R/V/S/L taken; `Q` names the end (the quality
> bar), the reorg being the means. Product framing, the Vital-grounded target shape, the
> grep-verified SOLID audit (the evidence base), and the fork record (Q-1..Q-6):
> `docs/product/code-organization.md`. When a point lands, doc-keeper moves it to `COMPLETED.md`.

## What it is

A directory + namespace + file-split reorganization that makes ReaSampler's **already-real,
CMake-enforced** architecture **legible in the code's shape**. The pure/shell split exists (30
pure static libs, each with its own test executable, none linking a REAPER SDK) but is invisible:
all 45 files sit in **one flat `src/`**, all 37 headers in **one flat `reasampler` namespace**,
four modules have grown into god-modules, and the JSON parser is copy-pasted across four models.
Phase Q gives the existing subsystem grouping — model / view / capture / audio / ui / reclaim /
version — a **structural home** (directories + namespaces), splits the four god-modules along
validated seams, and extracts the duplicated JSON into one pure module. Nothing about the
architecture *changes*; it becomes *visible*. This is why the phase can be zero-runtime-cost and
CTest-green throughout: the seams already exist in the link graph; Phase Q draws them where a
reader sees them.

## The quality bar — Vital (read from its actual `src/` tree)

Vital (`github.com/mtytel/vital`) groups its synth by **subsystem** — `common/` `synthesis/`
`interface/` `plugin/` — with `synthesis/` further subdivided by function (`synth_engine/
modulators/ filters/ effects/ producers/ framework/ lookups/ utilities/`). The grouping principle
is **layered functional architecture**: the directory tree *is* the architecture diagram; you read
the subsystem map off the folders. ReaSampler adopts the *pattern* (directory = architecture),
adapted to its own most load-bearing invariant — the pure/shell split — as the top level (see
below). Vital is GPLv3; the borrowed artifact is the **structural pattern**, not code.

## Settled decisions (Q-1 settled; Q-2..Q-6 recommended — see `docs/product/code-organization.md` §6)

- **Q-1 — namespace letter. SETTLED: `Q` (Quality).** Point-id family `Q1..Qn`, wave prefixes
  `Q-W1..Q-W6`. `O` (Organization) was set aside: the glyph reads ambiguously against zero in
  point ids, and "Organization" undersells a phase measured against a *quality* bar.
- **Q-2 — JSON extraction in scope + first. REC: yes.** The 4× duplicated `Parser` is the largest
  DRY+SRP violation and is entirely off the hot paths — the ideal safe, high-leverage opener
  (Q-W1).
- **Q-3 — directory shape. REC: `core/`/`shell/`/`app/` top-split, subsystem dirs beneath.** Top
  level by the pure/shell discipline (so the invariant is *structural*, not merely conventional),
  subsystem grouping one level down. Preferred over pure-Vital subsystem-first because ReaSampler
  has a pure/host split Vital lacks and that split is the invariant most worth protecting
  structurally.
- **Q-4 — sub-namespace to match sub-directory. REC: both.** `reasampler::model`/`view`/`capture`/
  `audio`/`ui`/`reclaim`/`version`/`json`. Directory and namespace agree; a symbol's home is
  unambiguous from either.
- **Q-5 — god-module split granularity. REC: to the audit's named seams, no finer.** Well-factored,
  not atomized.
- **Q-6 — OCP registration-table. REC: in scope, last (most droppable if narrowing).**
- **Q-7 — naming rides the relocation waves, no dedicated naming wave. REC: yes** (forced once
  Q-3/Q-4 settle — a rename is near-free during relocation, near-pure-churn standalone).
- **Q-8 — class/module renames beyond the free namespace fix. REC: fix the two that actively
  mislead** — `BankIndex`→`BankModel` (the `bank_model.h`/`BankIndex` file↔class word-mismatch)
  and the unified JSON parser → `json::Reader`/`json::Writer` (or `json::Parser`) — **leave the
  merely-quirky** (`Book`/`Bank`/`Index`, `Sample`/`AudioSample`, `MinMax`, `KitBox`). Daniel's
  to call.
- **Q-9 — align the `capture_realtime` (shell) / `realtime_record` (pure) word-order inversion.
  REC: yes, during Q-W3** (a free rider — W3 already hoists the realtime lifecycle).

## The directory + namespace map (Q-3 / Q-4)

Top-level by the pure/shell discipline; subsystem dirs beneath `core/`; namespaces mirror
directories.

**`core/` (pure, no REAPER types, unit-tested — `reasampler::<subsystem>`):**
- `core/model/` (`::model`) — `bank_model`, `bank_book`, `owned_manifest`, `provenance`
- `core/view/` (`::view`) — `view_mode_model`, `view_tree`, `lane_keys`, `mode_switch`
- `core/capture/` (`::capture`) — `render_settings`, `batch_capture`, `tail_control`,
  `capture_paths`, `wav_trim`, `insert_plan`
- `core/audio/` (`::audio`) — `peaks`
- `core/ui/` (`::ui`) — `theme`, `component_geometry`, `bank_grid`, `tab_strip`, `action_buttons`,
  `prune_button`
- `core/reclaim/` (`::reclaim`) — `prune_reconcile`
- `core/version/` (`::version`) — `app_version`
- `core/json/` (`::json`) — **NEW** — extracted parser/serializer (replaces the 4 duplicate
  `Parser`s)

**`shell/` (REAPER-facing — subdir by subsystem, namespace as house style prefers):**
- `shell/capture/` — `capture`, `capture_realtime`, `provenance_shell`, `track_guid`, `item_read`;
  **post-Q-W3** `capture_orchestrator`, `scope_resolve`, `realtime_lifecycle`
- `shell/panel/` — `draw_kit`; **post-Q-W2** `panel_render`, `panel_thumbnails`,
  `panel_audition`, `panel_input`, `panel_bank_ops`, `panel_window` (from `bank_panel`)
- `shell/view/` — `view`
- `shell/persist/` — **post-Q-W5** `session`, `ext_state_io`, `prune_fs` (from `persist`)
- `shell/actions/` — `drag_out_win`; **post-Q-W4** `design_view_actions`, `bank_actions`,
  `prune_action` (from `actions`)

**`app/`:** `main.cpp` (post-Q-W3: API-pointer ownership + `ReaperPluginEntry` + dispatch only).

**Collision sweep (before W1):** `Sample` (`::model`) vs `AudioSample` (`::audio` alias) vs the
unified `Parser` (`::json`) must not collide once flattened into granular namespaces; resolve by
subsystem home.

## The god-module split seams (Q-5)

Split each to the audit-validated seams, no finer:

- **`bank_panel.cpp` (2424 LOC → `shell/panel/`, Q-W2):** `panel_render` (draw/paint) /
  `panel_thumbnails` (compute+cache) / `panel_audition` (preview engine — **hot path, direct
  call-through**) / `panel_input` (mouse/key/wheel + new-content detection) / `panel_bank_ops`
  (bank CRUD — the single owner W4 dedupes against) / `panel_window` (lifecycle + OS
  drag-out/drop-target). Split the fat `bank_panel.h` per seam (I).
- **`main.cpp` (1762 LOC → hoist to `shell/capture/`, Q-W3):** `capture_orchestrator`
  (`RunCapture`/`captureAndIndexOne`/`renderOffline`/batch/recapture/realtime `Run*`) /
  `scope_resolve` (range/razor/track resolution + provenance assembly inputs) /
  `realtime_lifecycle` (state machine + globals + selection guards). `FxBypassGuard` moves out
  but **stays stack RAII** (precision-critical). `main.cpp` → `app/`, reduced to pointers + entry
  + dispatch.
- **`actions.cpp` (981 LOC → `shell/actions/`, Q-W4):** `design_view_actions` /
  `bank_actions` / `prune_action` (`doBankPruneFolder` — the single file-deletion action). Dedupe
  `promptText`/`mintBankId` + bank verbs against `panel_bank_ops`.
- **`persist.cpp` (766 LOC → `shell/persist/`, Q-W5):** `session` (lifecycle/poll +
  `BeginLoadProjectState` reload hook) / `ext_state_io` (serialization bridge + GUID minting +
  folder relocation) / **`prune_fs`** (prune scan + `deleteOrphanFile` via `SHFileOperationW` —
  the isolated single file-deletion authority).

## The JSON extraction (Q-2 / Q-W1)

Extract one pure **`core/json`** (`::json`): parser (`parseString`/`parseInt`/`parseKey`/
`skipValue` + escape) + serialize/emit helpers. Rewire `bank_model`, `bank_book`,
`view_mode_model`, `owned_manifest` onto it and **delete their four hand-rolled `Parser`s**.
Round-trip output must be **byte-identical** to before — this is a structural dedupe, not a format
change. Off all hot paths (serialization runs at save/load, never per frame) — safe to abstract
freely.

## The OCP registration-table (Q-6 / Q-W6)

Replace the ~350-line hand-written **non-table** action registration blocks (isolated in
`app/main.cpp` after Q-W3) with a **data-driven registration table**, so adding an action edits one
place, not four parallel `Register("command_id"/"gaccel"/"hookcommand")` sites. Unload
mirror-unregisters from the same table. Command-id + display strings stay **byte-identical**
(FOREVER-STABLE, per-channel — the Phase V V4 contract). Also split residual fat headers
(`capture.h`/`persist.h`) alongside their TU splits (I).

## The naming dimension (Q-7 / Q-8 / Q-9 — grep-verified audit in `docs/product/code-organization.md` §2b)

Alongside giving symbols a directory + namespace *home* (Q-3/Q-4), Phase Q gives
inconsistently/poorly-named symbols a consistent *name* — same Vital bar, orthogonal to the SOLID
focus. The audit (2026-07-27) is grep-verified; the load-bearing findings:

- **Already consistent — preserve verbatim:** the geometry-mirror verb vocabulary
  (`compute<Thing>Rects`/`compute<Thing>` + `hitTest<Thing>`, verified across `bank_grid` /
  `mode_switch` / `action_buttons` / `action_bar` / `tab_strip` / `prune_button` / `overflow_menu` /
  `footer_bar` / `card_drag` / `component_geometry`) and the uniform `<module>_tests` CTest suffix.
- **Collisions — resolved by the Q-4 sub-namespaces for free:** four `class Parser`
  (`bank_model.cpp` / `bank_book.cpp` / `owned_manifest.cpp` / `view_mode_model.cpp`) collapse to
  one `json::Parser` in Q-W1; the shared pure-UI rect types `FooterRect` / `ButtonRect` (defined in
  `prune_button.h`, reused by `footer_bar.h` under an explicit hand-collision "NAME NOTE") get one
  `ui::` owner; `Sample` (`model::`) vs `AudioSample` (`audio::`) de-collide by home.
- **Genuine renames (Q-8/Q-9 — Daniel's call):** `BankIndex`→`BankModel` (the `bank_model.h`
  file↔class word-mismatch — the worst legibility wart, rec: rename the class so the model family
  reads `BankModel`/`BankBook`/`ViewModeModel`); the unified JSON parser named `json::Reader`/
  `json::Writer` at W1 mint; align `capture_realtime`(shell)/`realtime_record`(pure) to the house
  shell↔core convention (`drag_out`↔`drag_out_win` is the model) during Q-W3.
- **Sequencing (Q-7):** renames ride the wave that already relocates/splits the file — **no
  dedicated naming wave.** Rule: *no rename lands on a file the wave isn't otherwise touching.* W1
  carries the collision + model-class renames; W2 the `panel_*` names; W3 the realtime word-order
  fix. Zero-behavior-change like the rest of Phase Q; verified by the module's own test executable.
- **Out of scope (never renamed):** the FOREVER-STABLE contract strings are not C++ symbols —
  `command_id` strings, action display names, ext-state namespace (`"reasampler"`/`"reasampler_beta"`)
  and keys, the `reasampler:` lane prefix, the VST3 class UID. Renaming a C++ class is orthogonal
  to these literals (audit §2b.5).

## Performance guardrails (HARD CONSTRAINT — Daniel's non-negotiable)

The reorg must cost **zero runtime.** The two hot paths must keep their exact call/inline shape;
the following are acceptance criteria on every point:

- **`peaks` envelope compute** (`computeEnvelope` / `lastFrameAboveThreshold` over full PCM): **NO
  virtual dispatch, NO `peaks` interface, NO added header→TU indirection.** `computeEnvelope`
  stays a **free function on `const std::vector<float>&`** so it inlines as today. Relocate +
  namespace only; never wrap in an abstraction.
- **Audition / preview**: `panel_audition` may be its own TU (Q-W2), but the call stays a **direct
  call-through, not virtual.**
- **Realtime-capture tick**: keep the idle fast-path a **single pointer test**;
  `realtime_lifecycle` (Q-W3) must not change the tick's branch shape.
- **JSON extraction** is **off all hot paths** — safe to abstract freely (why Q-W1 is the safe
  opener).
- **`FxBypassGuard`** runs per-capture, not per-frame — keep it **stack RAII** when it moves out
  of `main.cpp` (Q-W3); never heap-allocate or virtualize it.

**Net:** every recommended split falls on a cold path or preserves call/inline shape on the two
hot ones. *A split that would add a hot-path indirection is out of scope — rework it or drop it.*

## The GATE (load-bearing — Phase Q is last)

Phase Q is **gated on the tree being otherwise quiescent.** Daniel's plain readiness target:
**"when Phase S and L3 are finished."** As of 2026-07-27 the outstanding work is precisely
**(1) Phase S** merged to dev (the large second-artifact branch, currently on the phase-s worktree)
and **(2) Phase L L3** merged to dev (the VST restyle, itself gated on Phase S). **L1/L2/L4/L5/L6/L7
have already landed** (see `COMPLETED.md`) — the earlier "L2 + L3" wording was stale and is
corrected to **L3 only**. **D2** is functionally complete (D2-W1..W3-B landed; its lone open item,
a per-track lane-split panel indicator, is *explicitly deferred*, not a blocking residual). **M9**
(slots) is **abandoned** (Daniel, 2026-07-27) — will not be built. D2 is named here only so that
*reactivating* its deferred panel indicator re-arms the quiescence condition; neither D2 nor M9
blocks the gate today.
*Why:* Phase Q touches **nearly every file in `src/`** (relocate, re-namespace, split the four
largest TUs, plus the §2b renames); every large in-flight branch (Phase S on its worktree, L3 once
it lands) is diffed against the *current flat layout*, so landing a rename-and-relocate-everything
reorg mid-flight forces every open branch through the worst conflict class — a combinatorial
re-resolution, not a linear one. Phase Q is *last* precisely because it reshapes the ground every
other pillar stands on: landing it early taxes every subsequent phase; landing it last taxes
nothing. Re-confirm quiescence against dev before Q-W1.

> **M9 disposition — resolved (Daniel, 2026-07-27): abandoned.** M9 is out; it will not be
> reactivated. The gate remains satisfied; no re-arm condition applies.

## Wave sequencing (each independently landable, CTest-green at every step)

Big-bang is rejected — the CMake per-module static-lib + per-module test-executable seams make a
file move + namespace change **mechanically verifiable** (`ctest --test-dir build` green or not, at
every commit), a property only an *incremental* reorg uses. Risk-ordered:

- **Q-W1** — safe opener: `core/json` extract (delete 4 `Parser`s) + impose the directory/
  namespace layout on the 30 clean pure libs + clean shells (pure relocation, no logic change).
  All later waves assume this layout. **Carries the naming collision fixes + the model-class
  renames (Q-8), which are free during this relocation.**
- **Q-W2..Q-W5** — the four god-module splits, one per wave, risk-ordered (`bank_panel` →
  `main.cpp` → `actions.cpp` → `persist.cpp`). Q-W4 depends on Q-W2 (`panel_bank_ops` dedupe
  target); Q-W5 best after Q-W4 (`prune_action` → `prune_fs` routing); otherwise parallel-safe.
  **Q-W2 carries the `panel_*` names; Q-W3 carries the `capture_realtime`/`realtime_record`
  word-order fix (Q-9).**
- **Q-W6** — OCP registration-table + residual fat-header (I) splits. Depends on Q-W3 (registration
  code isolated first). Sequenced last; most droppable if narrowing.
- **Naming (Q-7): no dedicated wave** — every rename rides the wave already relocating/splitting
  its file; a rename that would touch an otherwise-untouched file is deferred.

## Precision / invariant implications (what Phase Q does NOT change)

- **The pure/shell split is strengthened, never dissolved.** Top-level `core/` vs `shell/` makes
  it *structural*; no file crosses the boundary; no `core/` file gains a REAPER type; the CMake
  per-module test executables stay green.
- **Every precision invariant holds.** Null test, bit-identical repeats, non-destructive capture,
  exact bounds — none is code Phase Q rewrites. `FxBypassGuard` moves but stays stack RAII with
  identical behavior.
- **Capture ≠ placement.** No hoisted `Run*` / `capture_orchestrator` path may gain an
  `InsertMedia` call during the split.
- **Single file-deletion authority is concentrated, never spread.** `prune_fs` (Q-W5) is the one
  module that deletes bytes; the reorg improves this invariant.
- **Relative-paths-only** in the persisted index — untouched.
- **On-the-wire/on-disk contract strings unchanged.** Command-id strings, action display names,
  ext-state namespaces, VST3 class UID are byte-identical (per-channel, per Phase V V4).
  Re-namespacing C++ symbols is orthogonal to these.
- **No behavior change.** Phase Q is a pure structural refactor; the test suite passing unchanged
  is the proof of correctness. A point that changes observable behavior has exceeded its charter.

## Non-goals / guardrails

- **No feature, no behavior change.** If a point ships anything user-visible, it is out of scope.
- **No hot-path indirection.** No virtual dispatch or header→TU indirection on `peaks`/audition/
  realtime-tick — ever (a hard acceptance bar, not advice).
- **No design-level (D-letter SOLID) rework.** `main`/`bank_panel` depending on concrete capture
  backends is a low-priority Dependency-Inversion concern — **out of scope** (a design change, not
  a reorg). Phase Q reorganizes; it does not re-architect interfaces.
- **No `peaks` data-ownership change.** `peaks` forcing a whole-file `std::vector<float>` copy on
  the thumbnail path is noted but **not touched** — reworking it risks the hot path.
- **No big-bang commit.** Every wave is independently landable and CTest-green; reject a change set
  that cannot be verified at each step.
- **Do not begin before the GATE.** Re-confirm the tree is quiescent (Phase S + L + D2 merged/closed;
  M9 abandoned) before any Q point.
- **Verify** the CMake `src/` path updates and the SWELL/LICE surfaces still resolve after
  relocation, as the existing build already requires.
