docs(multi-bank): frame Phase B — pool + named banks spec

Add the multi-bank pillar as Phase B (lettered, parallel to M-line and
Phase D): CONTEXT.md §Multi-bank spec, PLAN.md Phase B block (B1 bank_book
pure / B2 persist / B3 actions / B4 panel split), and docs/product/multi-bank.md
framing. Additive only — BankIndex, the M0–M11 roadmap, and Phase D untouched.
This commit is contained in:
2026-07-23 12:52:06 -04:00
parent 43ca5e4069
commit d9081090fd
3 changed files with 616 additions and 0 deletions
+189
View File
@@ -317,3 +317,192 @@ togglable per selection.
parked (ignore-and-prune stale GUIDs on next toggle/open).
- 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).
---
# 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). 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 (default).** 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* removes from source, adds to
destination (one logical sample, regrouped). *Copy* adds to destination and leaves
the source entry intact (same file, two index entries, two banks). Both are
index-only; both share the destination-collapse rule. Copy is what lets a sample
live in the pool *and* a named group at once.
- **Persistence: a new ext-state key, pool migrates in place.** The multi-bank
state serializes to a new key `banks` in the existing `"reasampler"` namespace,
alongside `bank_index`, `view_state`, and `project_guid`. **Migration:** on load,
if a `banks` key is absent but a legacy `bank_index` key is present, the legacy
index becomes the pool's `BankIndex` and the book is `{ pool }` with zero named
banks — a one-way, lossless promotion. (Whether `bank_index` is retired or kept as
the pool's canonical slot is an open fork — see below.)
- **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 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 does not delete its
samples' files (they may be referenced by the pool or another bank via copy);
file lifecycle stays owned by the capture/prune path, not the bank container. See
open fork on what "delete a named bank" does to its member samples.
- **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); 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 (banks + per-bank indices + active id +
ordinals) and legacy-`bank_index`→pool migration on parse.
REAPER-facing:
- `persist` (slice) — serialize/deserialize the book under the `banks` key in
`"reasampler"`; migrate a legacy `bank_index` key into the pool on first load;
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; sample move/copy affordance
(drag between regions and/or a "send to bank" menu on selection). Reuses the
existing LICE grid render loop per bank region.
- `actions` (entries) — create bank / rename bank / delete bank; 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 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 tab strip + toggles. SWELL controls for the tab
strip / toggle affordances follow the M5 docking pattern; **verify SWELL control
usage against the M5 reference**, 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,
or leave a project with zero banks. Enforce in the pure rules layer, not just the
UI.
- **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
- **`bank_index` key retirement vs. retention.** Two shapes: (a) the pool's index
rides *inside* the `banks` blob and the legacy `bank_index` key is retired after a
one-way migration; (b) the `bank_index` key is *kept* as the pool's canonical
storage slot and `banks` holds only the named banks + ordering + active id. (a) is
cleaner (one blob, one section) but rewrites where the pool lives; (b) is more
conservative (existing pool persistence untouched, named banks are pure addition)
at the cost of the pool being stored differently from named banks. Leaning (b) for
additive-minimalism against in-flight M7/M8 persist work — but this is a genuine
fork; see product notes.
- **What "delete a named bank" does to its members.** Options: (i) *reabsorb*
member samples move back to the pool (no sample is ever lost to a bank delete);
(ii) *orphan-check* — delete members whose file is referenced by no other bank,
keep the rest; (iii) *forbid non-empty delete* — require the bank be emptied
first. (i) is the safest and simplest mental model (a named bank is a *grouping*,
deleting the group returns things home) and pairs naturally with index-only
movement. Leaning (i); flag for Daniel.
- **Move via drag vs. menu as the primary affordance**, and whether the named-banks
region is REAPER-native tabs (SWELL tab control) or LICE-drawn tabs matching the
grid aesthetic. UI-mechanics detail for the panel build; verify SWELL tab-control
availability against the M5 reference.
- **Active-bank indicator placement** — in the pool/bank region headers, or a
single header readout. Panel-polish detail.