# 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. - **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: auto-tag by active mode at creation.** New content — **both new tracks and new items** — is tagged to whatever mode is active when it is created. Pre-existing content defaults to **Arrange**. Membership is **exclusive**: Design-created content never appears in Arrange and vice versa, except via the existing **show-both** escape hatch. (This is the same tag-to-active-mode rule as D1 track membership, now reaching 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/item GUIDs + the active mode* — and, for items, whether the item landed in a manual lane (exempt) — produce the membership writes. - **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; **any GUID new since the last poll is tagged to the then-active mode.** - 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 follows the active-mode 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) is shell; the **tagging decision** and the **managed/manual lane query** (new GUIDs + 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). - **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. - **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`. ## 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. - **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. ## 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). - **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. - **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.