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.
18 KiB
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:
- It modifies
BankIndex, the tested heart. The whole discipline is thatbank_modelis the pure, hard-tested core. AbankIdfield threads bank awareness through the one module that should stay bank-agnostic. A container wrapsBankIndexand leaves it byte-for-byte as tested. - Dedup-by-hash wants per-bank scope.
BankIndexalready 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 abankIdfield would need dedup to become "dedup within abankIdpartition," reimplementing per-bank scoping that a container gets for free. - It mirrors the two pure cores we already have.
bank_modelandview_mode_modelare both "a pure registry with JSON round-trip, unit-tested outside the DAW."bank_bookis the third instance of that exact pattern. AbankIdfield 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
Samplefrom A'sBankIndexand 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
BankIndexdedups 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 collapseBankIndex::addalready 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
BankIndexin 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. BankIndexis untouched —bank_bookwraps, never modifies it.
persist slice:
- Serialize/deserialize the book under the
bankskey in"reasampler"(shared blob, distinct section frombank_index/view_state). - Migrate a legacy
bank_indexkey 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
BankIndexis 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
bank_indexkey retirement vs. retention. Two persistence shapes: (a) fold the pool's index into thebanksblob and retire the legacybank_indexkey after a one-way migration — cleaner, one blob, one section; (b) keep thebank_indexkey 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 existingbank_indexwrite path alone. But it's a genuine fork with a real trade — your call before B2 is scoped.
- ordering + active id under
- 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.
- 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.
- 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.
- 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.