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
+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.