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:
+189
@@ -317,3 +317,192 @@ togglable per selection.
|
|||||||
parked (ignore-and-prune stale GUIDs on next toggle/open).
|
parked (ignore-and-prune stale GUIDs on next toggle/open).
|
||||||
- Interaction with the user having a screenset active (Design View drives the same
|
- 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).
|
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
|
||||||
|
> M0–M11 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 M0–M6 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 M0–M11 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.
|
||||||
|
|||||||
@@ -79,3 +79,106 @@ landed milestone.
|
|||||||
in int64 but exceed `INT_MAX` are implementation-defined. Hardening candidate
|
in int64 but exceed `INT_MAX` are implementation-defined. Hardening candidate
|
||||||
— add bounds check before the cast when integer-field validation is in scope.
|
— add bounds check before the cast when integer-field validation is in scope.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase B — Multi-bank (parallel to the M0–M11 capture roadmap and Phase D)
|
||||||
|
|
||||||
|
> **Separate phase namespace.** The M-numbers belong to the capture pillar
|
||||||
|
> (M0–M11); 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)
|
||||||
|
|||||||
@@ -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