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:
@@ -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.
|
||||
Reference in New Issue
Block a user