Files
reasampler/CONTEXT.md
T

742 lines
47 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
> M0M11 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
> M0M11 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 ~812-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 M0M6 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 M0M11 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 15 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 ~812-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.