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.
+103
View File
@@ -79,3 +79,106 @@ landed milestone.
in int64 but exceed `INT_MAX` are implementation-defined. Hardening candidate
— add bounds check before the cast when integer-field validation is in scope.
---
# Phase B — Multi-bank (parallel to the M0M11 capture roadmap and Phase D)
> **Separate phase namespace.** The M-numbers belong to the capture pillar
> (M0M11); the D-letters belong to Design View. Multi-bank is a third orthogonal
> pillar — generalizing the single bank into a pool + named banks — so it takes its
> own **lettered** namespace (B1, B2, …). "B" reads for **Banks** and, like Phase D,
> keeps the roadmaps from colliding on numbering: Phase B is not "the twelfth
> capture step," it is a different pillar. Authoritative spec: **CONTEXT.md
> §Multi-bank**. Product framing: `docs/product/multi-bank.md`. When a point lands,
> doc-keeper moves it to `COMPLETED.md`.
## B1 — bank_book (pure)
**Goal:** REAPER-free bank registry wrapping N `BankIndex` instances: pool seeded +
privileged, create/rename/reorder/delete named banks, active-bank id, move/copy a
sample between banks, JSON round-trip + legacy-migration. The heart of the phase;
mirror of `bank_model` / `view_mode_model`; **`BankIndex` untouched (additive)**.
CONTEXT.md §Multi-bank (Module architecture — pure).
**Verify:** CTest green. Pool always present, un-deletable, un-renamable (rules
rejected in-model). Active-bank defaults to pool. Move is index-only (source loses
entry, destination gains it) and observes destination collapse-by-hash; copy leaves
source intact. JSON round-trip lossless across banks + per-bank indices + ordinals +
active id. Legacy `bank_index` JSON parses into `{ pool }` with zero named banks.
- [ ] Bank registry: ordered `{ bank id, display name, ordinal, BankIndex }`; pool
seeded with fixed id + fixed name; create / rename / reorder / delete named banks.
- [ ] Pool-privilege rules enforced in-model: reject delete-pool, reject
rename-pool, never allow zero banks.
- [ ] Active-bank id (get/set; defaults to pool); resolve active bank's `BankIndex`.
- [ ] Move sample between banks (index-only; destination collapse-by-hash observed;
source entry removed).
- [ ] Copy sample between banks (index-only; source entry retained; destination
collapse-by-hash observed).
- [ ] JSON round-trip: banks + per-bank indices + ordinals + active id.
- [ ] Legacy migration: a bare `bank_index` JSON promotes to the pool's index with
zero named banks (one-way, lossless).
- [ ] Tests: pool privileges (delete/rename rejected); move source-loses/dest-gains;
copy source-retained; cross-bank same-hash coexistence; dest collapse on move into
a bank already holding the hash; JSON lossless; legacy migration.
## B2 — persist slice (banks ↔ project ext state)
**Goal:** Serialize the book under the `banks` key in `"reasampler"` alongside the
existing sections; migrate a legacy `bank_index` key into the pool on first load;
reload-on-open and Save-As survival via the existing M4 machinery. CONTEXT.md
§Multi-bank (persist). **Verify (in DAW):** Banks + named banks + active bank + all
per-bank samples survive Save / Save As / close+reopen; **relative paths only**;
bank travels with the `.rpp`; a project saved before this phase (legacy `bank_index`
only) loads as pool + zero named banks with no sample loss.
**Depends on:** B1. Resolve the `bank_index` retirement-vs-retention fork first
(see Phase B open questions).
- [ ] Serialize/deserialize the book under the `banks` key (shared blob, distinct
section from `bank_index` / `view_state`).
- [ ] Legacy-migration path on load: absent `banks` + present `bank_index` → pool.
- [ ] Session exposes the book; the active bank's `BankIndex` is the capture add
target (route the M7 capture family through it — additive to M7, no M7 rewrite).
- [ ] Confirm survival across Save / Save As; confirm legacy-project load path.
## B3 — actions
**Goal:** Bindable action set for the multi-bank workflow. CONTEXT.md §Multi-bank
(actions). **Verify (in DAW):** Each action registered (bindable in Actions list);
bank-activate + move/copy MIDI-bindable; create/rename/delete drive the B1 model via
the B2-persisted session.
**Depends on:** B1, B2.
- [ ] Create bank / rename bank / delete bank (delete honors the resolved
member-disposition rule — see open questions).
- [ ] Activate bank (direct-by-id + cycle).
- [ ] Move selected samples → bank / copy selected samples → bank.
- [ ] Pool full-height / banks full-height toggles.
- [ ] Register each (`command_id`/`gaccel`/`hookcommand`); bank-activate + move/copy
MIDI-bindable.
## B4 — bank_panel vertical split (UI)
**Goal:** The vertical-split bank window — pool on top, named-banks tab-page region
below, full-height toggles — extending the M5 docked grid. CONTEXT.md §Multi-bank
(bank_panel). **Verify (in DAW):** Pool grid renders on top; named-banks tab strip
below (empty when no named banks, one tab per named bank); active-bank indicated;
both full-height toggles collapse the split correctly; sample move/copy affordance
works (drag and/or menu); the Design View mode switch in the header is unaffected.
**Depends on:** B1, B2, B3.
- [ ] Vertical split: pool grid region (top) + named-banks tab-page region (bottom).
- [ ] Named-banks tab strip: one tab per named bank; empty state when none.
- [ ] Pool full-height / banks full-height toggle affordances wired to B3.
- [ ] Active-bank indicator.
- [ ] Create / rename / delete / activate affordances driving B3 actions.
- [ ] Sample move/copy affordance (drag between regions and/or "send to bank" menu).
## Phase B open questions
- **`bank_index` key retirement vs. retention** — (a) fold the pool into the `banks`
blob and retire the legacy key, vs (b) keep `bank_index` as the pool's canonical
slot and store only named banks under `banks`. Leaning (b) for additive-minimalism
against in-flight M7/M8 persist work. Resolve before B2. (blocks B2)
- **Named-bank delete → member disposition** — (i) reabsorb into pool, (ii)
orphan-check by file reference, (iii) forbid non-empty delete. Leaning (i).
(touches B1/B3)
- **Move affordance + tab rendering** — drag vs. menu as primary; SWELL-native vs.
LICE-drawn tabs. Verify SWELL tab-control availability against the M5 reference.
(touches B4)
- **Active-bank indicator placement** — per-region headers vs. single header
readout. (touches B4)
+324
View File
@@ -0,0 +1,324 @@
# Multi-bank — product notes
Framing, rationale, and design-direction calls behind the **Multi-bank** phase.
The tickable spec lives in `PLAN.md` (Phase B) and the authoritative technical
detail in `CONTEXT.md` (§Multi-bank). This doc holds the *why* — the workflow
narrative, the pool-privilege reasoning, the movement semantics, and the
design-direction recommendations — so those don't clutter the build docs.
Status: framed by product-designer (2026-07-23), pending Daniel review. Open items
for Daniel are listed at the bottom.
---
## What it is (and what it is not)
**Multi-bank generalizes the single per-project bank into a pool plus named
banks.** Today ReaSampler has exactly one bank (`BankIndex`) — every capture lands
in it, and it is the whole library. That is fine at ten samples and unusable at two
hundred. Multi-bank keeps the one bank as **the pool** — the default catch-all
every capture still lands in — and lets the user create **named banks** ("Drums",
"1-Shots", "Synth Hits") that group samples for a purpose. Samples move (or copy)
freely between any banks, including to and from the pool. One bank at a time is the
**active bank**: the capture target. The pool is active by default.
**It is not a new file layout.** Banks are *logical groupings over one shared file
pool*, not folders on disk. Moving a sample between banks moves an index entry, not
a `.wav`. This is deliberate: it keeps movement cheap and non-destructive, and it
keeps every file exactly where M4's project-relative path resolution already puts
it. Anyone expecting "move to Drums" to reorganize the bank folder on disk will be
surprised — and that expectation is headed off in the spec, not discovered in
review. (See *movement semantics* below.)
**It is not a change to how capture works.** The capture pillar's load-bearing rule
stands untouched: capturing writes a file plus an index entry and never inserts into
the arrange. Multi-bank changes exactly one thing — *which index* the entry lands
in. Everything downstream of "add a `Sample` to a `BankIndex`" is unchanged.
---
## The pool is to banks what Arrange is to modes
This phase deliberately reuses the structural pattern Phase D established for modes,
because the problems are the same shape.
In Design View, **Arrange** is structurally just another mode in an N-mode registry,
but semantically privileged: it is the default home for every untagged leaf. The
data model is uniform (no Arrange-shaped special type); the *rules layer* enforces
Arrange's privilege.
In Multi-bank, **the pool** is structurally just one `BankIndex` in an N-bank
registry, but semantically privileged: it always exists, is un-deletable, and is
un-renamable, and it is the default capture target and the default home for a
sample that isn't grouped anywhere else. Same move: uniform data model (a bank is a
bank), privilege enforced in the rules layer (`bank_book` rejects delete-pool /
rename-pool / zero-banks).
Why this over a pool-shaped special type? A special pool type would fork every
operation into "pool path" and "named-bank path" — serialize, iterate, render,
move-target-resolution all branch. Treating the pool as bank-zero with three
enforced rules keeps one code path and one JSON shape, and the privileges live in
exactly one place. This is the same reasoning that kept Arrange from being a boolean
special case.
---
## Why a container, not a bank-id on the sample
The obvious alternative to a container is a `bankId` field on `Sample` plus one flat
`BankIndex`: filter by `bankId` to get a bank's contents. Rejected, for three
reasons:
1. **It modifies `BankIndex`, the tested heart.** The whole discipline is that
`bank_model` is the pure, hard-tested core. A `bankId` field threads bank
awareness through the one module that should stay bank-agnostic. A container
*wraps* `BankIndex` and leaves it byte-for-byte as tested.
2. **Dedup-by-hash wants per-bank scope.** `BankIndex` already dedups by content
hash *within itself*. That is exactly the semantics we want per bank — and it is
what makes copy meaningful (the same hash can live in the pool and in "Drums").
A flat index with a `bankId` field would need dedup to become "dedup within a
`bankId` partition," reimplementing per-bank scoping that a container gets for
free.
3. **It mirrors the two pure cores we already have.** `bank_model` and
`view_mode_model` are both "a pure registry with JSON round-trip, unit-tested
outside the DAW." `bank_book` is the third instance of that exact pattern. A
`bankId` field would be a fourth, different pattern bolted onto the first.
So: `bank_book` is an ordered registry of `{ bank id, display name, ordinal,
BankIndex }`, pool seeded as bank-zero. Bank id is the stable key (minted GUID-style
on create); name and ordinal are mutable. `BankIndex` is untouched. This is the
defer-the-feature, design-the-seam principle: the seam is a container above the
tested core, not a modification of it.
---
## Movement semantics (the settled rules, in prose)
- **Move is index-only.** Moving a sample from bank A to bank B removes the `Sample`
from A's `BankIndex` and adds it to B's. The file never moves — one shared file
pool under the project bank folder, exactly where M4 put it. Cheap,
non-destructive, immune to the path-rewrite bug class M4 closed.
- **Copy is index-only too.** Copy adds the sample to B and *leaves it in A*. Two
index entries, one file, two banks. Copy is the mechanism that lets a sample live
in the pool *and* in a named group at once — the pool stays the complete library,
and "Drums" is a curated view into part of it.
- **Dedup-by-hash is per-bank, and collapse is observed across a move.** Each
`BankIndex` dedups within itself, unchanged. If you move (or copy) a sample into a
bank that already holds its hash, the destination *collapses* onto its existing
entry — the same collapse `BankIndex::add` already does, now visible across a
move. On a *move* the source entry is still removed, so the sample ends up in the
destination once, as expected.
- **No cross-bank dedup.** The same hash may exist in the pool and in a named bank
simultaneously. That is the whole point of copy — do not add a global dedup that
collapses across banks (guardrail in the spec).
Why index-only movement over moving files into per-bank subfolders? The subfolder
approach makes "which bank" a filesystem fact, which reads tidy — but it reintroduces
exactly the path-rewrite fragility M4 spent three iterations eliminating (Save-As
relocation, relative-path resolution, GUID-primary identity). Every bank-move would
be a file operation that has to stay non-destructive and relative-path-correct
across Save-As. Index-only movement sidesteps all of it: files never move, so paths
never rewrite, so none of that machinery is re-exercised. Banks are a *view* concept,
files are a *storage* concept, and keeping them separate is what keeps movement
trivially correct.
---
## Persistence and migration
The book rides the existing `"reasampler"` project ext-state namespace under a new
key, `banks`, alongside `bank_index`, `view_state`, and `project_guid`. Same
namespace, same travel-with-the-`.rpp` guarantee, same GUID-primary identity and
Save-As-relocation machinery from M4. One shared physical bank folder; one
ext-state namespace; now three logical sections.
**Migration of an existing single-bank project is the load-bearing edge case.** A
project saved before this phase has a `bank_index` key and no `banks` key. On load,
that legacy index becomes *the pool's* `BankIndex`, and the book is `{ pool }` with
zero named banks. One-way, lossless promotion — no sample is lost, no path changes,
the user opens their old project and finds everything in the pool exactly as before,
now with the ability to add named banks. This must be a first-class, tested path
(B1 pure-model migration + B2 in-DAW load), not an afterthought.
There is one genuine fork here, called out in the open items: whether the pool's
index lives *inside* the `banks` blob (retire the legacy `bank_index` key) or the
`bank_index` key is *kept* as the pool's canonical slot with `banks` holding only
named banks. Both migrate cleanly; the trade is cleanliness (one blob) vs.
additive-minimalism (existing pool persistence untouched, named banks pure
addition). The lean recommendation is the conservative one — see open items.
---
## Precision-invariant implications
Multi-bank sits *above* the file entirely, so the capture-side invariants
(determinism, bit-identical repeats, null test, exact bounds) are untouched — they
are properties of the capture path and the file. The invariants it *does* touch:
- **Relative-paths-only** is enforced N times instead of once — once per
`BankIndex` in the book, by the exact code that enforces it today. Movement adds
no path handling because files never relocate.
- **Non-destructive** extends to bank operations: create / rename / delete /
activate and sample move / copy mutate only index + ext-state. No file is written,
moved, or deleted; no timeline item is touched. In particular, **deleting a named
bank does not delete its samples' files** — a file may be referenced by the pool
or another bank via copy, and file lifecycle stays owned by the capture/prune
path, never the bank container.
- **Travels-with-the-.rpp** is preserved by riding the M4 machinery unchanged.
---
## Design-direction recommendations (opinionated)
### The pool stays the complete library; named banks are curated views
The strongest mental model — and the one the index-only, copy-friendly design is
built for — is: **the pool is everything you've captured; named banks are curated
subsets you assemble by hand.** Copy (not move) into a named bank keeps the pool
complete, so the user can always fall back to "it's in the pool somewhere" while
"Drums" stays a clean working set. Move is there for the user who wants a strict
partition, but copy is the gentler default the UI should make easy. This is
borrowed from playlist-vs-library models (music apps, Lightroom collections): the
library is authoritative and complete; collections are lightweight views over it.
Recommendation: make **copy the low-friction gesture** (drag, or a one-click "add to
bank") and **move the deliberate one** (explicit "move" menu item). Do not force the
user to choose partition semantics up front.
### The vertical split: pool on top, named banks as a tab strip below
Daniel's directive is a vertical split — pool on top, named banks below — with
full-height toggles for either region. This reads well against the "pool is the
library, banks are views" model: the library is the persistent top region, the
curated views are the swappable bottom region.
- **Named banks as a tab strip** (one tab per named bank, one bank visible at a
time in the region) rather than a stack of grids. A tab strip scales to many banks
without eating vertical space, and "one active tab" pairs with "one active bank"
cleanly (though *shown* tab and *capture-active* bank are distinct — see below).
Borrowed from browser/IDE tab strips and sample-library browsers (Ableton's
collections rail, Kontakt's multi rack).
- **Full-height toggles** collapse the split to one region: *pool full-height* (hide
named banks — "I'm just capturing into the pool right now") and *banks full-height*
(hide the pool — "I'm organizing"). These are mode-of-work toggles, not layout
fiddling, and they map to the two halves of the workflow (capture vs. curate).
### Keep "active bank" distinct from "shown tab"
A subtle but important call: the **capture-active** bank (where new captures land)
and the **currently-shown** named-bank tab are *different* concepts. You might be
looking at "Drums" while capturing into the pool, or vice versa. Conflating them
("the tab you're viewing is the capture target") would make it too easy to
capture into the wrong bank by merely browsing.
Recommendation: **active bank is an explicit state with a clear indicator**, set by
an explicit "activate" affordance (and action), *not* implied by which tab is shown.
The pool is active by default; activating a named bank is a deliberate act. The
indicator should be unmistakable — the capture target is a thing you want to be sure
of before you hit capture. Placement (per-region header vs. single readout) is a
panel-polish open item.
### The Design View mode switch and multi-bank are orthogonal — and should read that way
The window header already carries the Design View segmented mode switch
(`[ Arrange | Design ]`). That governs *timeline visibility*. Multi-bank governs
*sample grouping*. They are fully orthogonal — you can be in Design mode capturing
into "Synth Hits," or in Arrange mode capturing into the pool. The UI must not
suggest a coupling: keep the mode switch where it is (header, timeline concern) and
the bank controls in the bank body (library concern). Do not, for instance, put bank
tabs next to the mode segments as if they were the same kind of switch.
### Tagging vs. banking are different verbs — don't blur them
Worth stating because both phases involve "putting a thing into a named group."
Design View **tags tracks** into modes (a track-visibility concern). Multi-bank
**moves samples** into banks (a library-organization concern). Different objects
(tracks vs. samples), different purpose (timeline stance vs. library grouping). The
vocabulary should stay distinct — "tag into Design" vs. "move to Drums" — so a user
never conflates the two systems. They rhyme structurally (both are N-collections
with a privileged default) but they are not the same feature and should not share
UI metaphors beyond what's genuinely shared.
---
## Action set (proposed)
Stable command-id strings follow the sampler family prefix. Bank-activate and
move/copy are MIDI-bindable to suit the capture-heavy, hands-on workflow.
- **Create bank** / **Rename bank** / **Delete bank** — manage the named-bank set.
(Pool is un-deletable / un-renamable — the actions refuse on the pool.)
- **Activate bank** (direct-by-id) / **Cycle active bank** — set the capture target;
distinct from browsing a tab.
- **Move selected samples → bank** / **Copy selected samples → bank** — the two
movement verbs; copy the low-friction default per the recommendation above.
- **Pool full-height** / **Banks full-height** (toggles) — collapse the split.
---
## Module breakdown (pure / persist / actions / UI)
Mirrors the capture and Design View pillars exactly.
**Pure `bank_book` (REAPER-free, unit-tested — the mirror of `bank_model` /
`view_mode_model`):**
- Ordered bank registry: `{ bank id, display name, ordinal, BankIndex }`; pool
seeded with fixed id + fixed name.
- Create / rename / reorder / delete named banks; pool-privilege rules enforced
here (reject delete-pool, reject rename-pool, never zero banks).
- Active-bank id (get/set, defaults to pool); resolve the active bank's `BankIndex`.
- Move / copy a sample between banks — index-only, destination collapse-by-hash
observed, move removes the source entry.
- JSON round-trip of the whole book (banks + per-bank indices + ordinals + active
id); legacy-`bank_index`→pool migration on parse.
- `BankIndex` is untouched — `bank_book` wraps, never modifies it.
**`persist` slice:**
- Serialize/deserialize the book under the `banks` key in `"reasampler"` (shared
blob, distinct section from `bank_index` / `view_state`).
- Migrate a legacy `bank_index` key into the pool on first load.
- Reload-on-open and Save-As survival via the existing M4 machinery; the session
exposes the book; the active bank's `BankIndex` is the capture add target.
**`actions` entries:** the set listed above, registered with the
`command_id` / `gaccel` / `hookcommand` pattern; bank-activate + move/copy
MIDI-bindable.
**UI (extending the M5 `bank_panel`):** the vertical split (pool grid top,
named-banks tab-page region bottom); the two full-height toggles; the active-bank
indicator; create / rename / delete / activate affordances; the sample move/copy
affordance (drag between regions and/or a "send to bank" menu on selection). Reuses
the M5 LICE grid render loop per region.
---
## Open items for Daniel
1. **`bank_index` key retirement vs. retention.** Two persistence shapes: (a) fold
the pool's index *into* the `banks` blob and retire the legacy `bank_index` key
after a one-way migration — cleaner, one blob, one section; (b) *keep* the
`bank_index` key as the pool's canonical storage slot and store only named banks
+ ordering + active id under `banks` — more conservative, existing pool
persistence untouched, named banks a pure addition. I lean **(b)** because M7/M8
persist work is in flight in another worktree and (b) leaves the existing
`bank_index` write path alone. But it's a genuine fork with a real trade — your
call before B2 is scoped.
2. **Named-bank delete → member disposition.** When a user deletes a *named* bank
holding samples: (i) *reabsorb* — members move back to the pool, nothing is ever
lost to a delete; (ii) *orphan-check* — delete members no other bank references,
keep the rest; (iii) *forbid non-empty delete* — require emptying first. I lean
**(i)** — a named bank is a grouping, deleting the group returns things home, and
it pairs naturally with index-only movement. Confirm or redirect.
3. **Move vs. copy defaults.** I'm recommending **copy as the low-friction gesture**
(drag / one-click) and **move as the deliberate one** (explicit menu), on the
"pool is the complete library" model. If you'd rather move be the default (strict
partition mental model), say so — it changes the primary affordance.
4. **Active-bank vs. shown-tab separation.** I'm recommending these stay *distinct*
(browsing a tab does not change the capture target; activation is explicit). If
you'd prefer the simpler "the tab you're viewing is the capture target" coupling,
flag it — it's a real simplification but risks capturing into the wrong bank by
browsing.
5. **Tab rendering + move affordance mechanics.** SWELL-native tab control vs.
LICE-drawn tabs matching the grid aesthetic; drag-between-regions vs. menu as the
primary move gesture. Panel-build detail — noted for B4, not phase-defining, and
the SWELL tab-control availability needs verification against the M5 reference.