d9081090fd
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.
509 lines
30 KiB
Markdown
509 lines
30 KiB
Markdown
# ReaSampler — implementation briefing
|
||
|
||
A native C++ REAPER extension that captures any arbitrary audio source into a
|
||
per-project **sample bank** (cached files + a docked grid), decoupled from the
|
||
arrange view, with keyboard/MIDI-bindable capture and placement. Built as a
|
||
precision tool: deterministic, non-destructive, no clutter.
|
||
|
||
This document is the spec. Read the existing scaffold first (`src/main.cpp` is
|
||
the REAPER<->extension contract; the pure/testable-core split in `src/mpe_model.*`
|
||
is the pattern to preserve — the MPE model is being replaced, the *discipline*
|
||
is not). Verify every REAPER API name and signature against
|
||
`vendor/reaper-sdk/sdk/reaper_plugin_functions.h` before use — the API names in
|
||
this brief are correct-by-intent but treat them as hints, not gospel, and check
|
||
argument order/types.
|
||
|
||
## The load-bearing principle
|
||
|
||
**Capture and placement are separate acts.** Capturing audio writes a file to
|
||
the bank and adds an index entry — it NEVER puts an item in the arrange. Placement
|
||
is a distinct, on-demand action (insert / drag). Any code path that auto-inserts a
|
||
capture into the timeline violates the entire point of the tool and must be
|
||
rejected in review. This single rule is why the tool exists.
|
||
|
||
## Settled decisions
|
||
|
||
- **Capture modes:** offline render (deterministic, the default) AND realtime
|
||
record (for hardware / performed FX). Both sit behind one capture interface and
|
||
produce identical bank entries.
|
||
- **Bank scope:** per-project, travels with the `.rpp`. Files live in a
|
||
project-relative subfolder; the index persists in project ext state. No absolute
|
||
paths anywhere in the index.
|
||
- **Material:** must handle full-mix/stem bounces, chops/one-shots, and
|
||
single-cycle/wavetable grabs equally. That means exact sample-accurate bounds,
|
||
explicit tail control, channel-count preservation, and loop/zero-crossing
|
||
handling all matter from day one.
|
||
|
||
## Module architecture
|
||
|
||
Preserve the scaffold's split: pure, REAPER-free logic in one set of files
|
||
(unit-tested outside the DAW via the existing `tests/` + CTest harness), REAPER-
|
||
facing shells in another.
|
||
|
||
Pure (no REAPER types, fully unit-tested):
|
||
- `bank_model` — the `Sample` metadata struct and the `BankIndex` (add / remove /
|
||
query / tier moves / dedup-by-hash) plus JSON serialize/deserialize to a
|
||
`std::string`. This is the heart; test it hard.
|
||
- `peaks` — compute waveform min/max bins from raw PCM. Feed it a known signal
|
||
(sine, ramp) and assert the envelope. We compute our own thumbnails from the
|
||
captured file rather than depending on REAPER's peak API — we own the file
|
||
format, so this is simpler, testable, and dependency-free.
|
||
|
||
REAPER-facing:
|
||
- `capture` — the `ICaptureBackend` interface plus `OfflineRenderBackend` and
|
||
`RealtimeRecordBackend`. Input: a `CaptureRequest` (source mode, time range,
|
||
wet/dry, tail, SR/bit-depth/channels, output path). Output: a finished file +
|
||
a populated `Sample` handed to `bank_model`.
|
||
- `insert` — placement via `InsertMedia`; conform-to-project-tempo vs literal,
|
||
as an explicit flag (never silent stretching).
|
||
- `bank_panel` — the docked grid: LICE-drawn thumbnails, audition, multi-select,
|
||
keyboard navigation. Reuses the docking setup already in `mpe_view.cpp`.
|
||
- `persist` — project ext state <-> `bank_model` JSON; project-relative path
|
||
resolution (resolve bank folder from the current project path).
|
||
- `actions` — registers the capture/placement/slot action family and routes each
|
||
to the modules above (the `command_id` + `gaccel` + `hookcommand` pattern from
|
||
`main.cpp`).
|
||
|
||
## Data model (sketch — refine in code)
|
||
|
||
`Sample`: id, display name, relative file path, source mode, source range (start/
|
||
end in project time + PPQ), track GUID(s) if applicable, wet/dry, channel count,
|
||
sample rate, length (seconds + musical/beats), capture tempo, optional key,
|
||
peak/RMS/LUFS, clip flag, tier (scratch | archive), content hash, provenance
|
||
(parent sample id + FX-chain snapshot string, when resampled from another sample),
|
||
created timestamp.
|
||
|
||
`BankIndex`: ordered collection of `Sample`, keyed by id; hash lookup for dedup;
|
||
tier filtering; JSON round-trip. Scratch tier is auto-prunable; archive is kept.
|
||
|
||
## REAPER API surface (verify all signatures)
|
||
|
||
Offline render (the crux — prototype this first):
|
||
- Drive render settings with `GetSetProjectInfo` (`RENDER_BOUNDSFLAG`,
|
||
`RENDER_STARTPOS`, `RENDER_ENDPOS`, `RENDER_TAILFLAG`, `RENDER_TAILMS`,
|
||
`RENDER_SRATE`, `RENDER_CHANNELS`, `RENDER_SETTINGS` for source = master mix /
|
||
selected tracks / selected items / time selection, wet vs dry) and
|
||
`GetSetProjectInfo_String` (`RENDER_FILE`, `RENDER_PATTERN`, `RENDER_FORMAT`).
|
||
- Trigger a no-dialog render via the appropriate render action / `RENDER_SETTINGS`
|
||
bit. Confirm the exact command id and the "render without opening dialog" flag
|
||
against current REAPER — do not assume; test that it runs headless.
|
||
- Determinism is a hard requirement: two identical requests must produce
|
||
bit-identical files (enables the null test below).
|
||
|
||
Realtime record:
|
||
- Standard "resample track" recipe: create a hidden track, set its record mode to
|
||
record-output (latency-compensated) or route the source to it via a send, arm
|
||
(`I_RECARM`), `CSurf_OnRecord`, run for the range, `CSurf_OnStop`, then move the
|
||
recorded source file into the bank and delete the temp track. Verify
|
||
`I_RECMODE` / `I_RECINPUT` values for output-recording.
|
||
|
||
Sources & metadata:
|
||
- Time selection: `GetSet_LoopTimeRange`. Razor edits:
|
||
`GetSetMediaTrackInfo_String(track, "P_RAZOREDITS", ...)`. Tempo:
|
||
`Master_GetTempo` / `TimeMap2_timeToBeats` / `GetProjectTimeSignature2`.
|
||
Selected items/tracks: `CountSelectedMediaItems` / `GetSelectedTrack`.
|
||
|
||
Placement:
|
||
- `InsertMedia(path, mode)` at edit cursor / new track / replace selection
|
||
(verify mode bits). `SetEditCurPos`. Wrap edits in `Undo_BeginBlock2` /
|
||
`Undo_EndBlock2`.
|
||
|
||
Persistence & paths:
|
||
- `SetProjExtState` / `GetProjExtState` (namespace e.g. `"reasampler"`) for the
|
||
index JSON. Resolve project folder via `EnumProjects` / `GetProjectPathEx`;
|
||
store the bank under a project-relative subfolder; keep only relative paths in
|
||
the index.
|
||
|
||
## Build order (each milestone independently testable)
|
||
|
||
1. `bank_model` + JSON round-trip + unit tests. (pure — no REAPER)
|
||
2. `peaks` + unit tests. (pure)
|
||
3. Offline capture of the time-selection master mix to a wav in the project bank
|
||
folder; add a `Sample`; log it to the console. (the render-driving spike)
|
||
4. `persist`: write the index to proj ext state, reload on project open; confirm
|
||
it survives Save / Save As. (bank travels with the .rpp)
|
||
5. `bank_panel`: docked grid with thumbnails, audition, selection.
|
||
6. `insert`: "insert selected sample at edit cursor" action via `InsertMedia`.
|
||
7. Capture action family: master / selected tracks / selected items / razor area,
|
||
each with wet-dry and tail options, all registered as bindable actions.
|
||
8. `RealtimeRecordBackend` behind the same interface.
|
||
9. Slots: "capture to slot N" / "insert slot N", MIDI-bindable (MPC-style).
|
||
10. Provenance + "re-capture from source"; null-test verify action.
|
||
11. Polish: batch capture (per selected item / per razor area), resample-and-mute-
|
||
source, conform-on-insert, drag-out to OS.
|
||
|
||
## Precision invariants (enforce, test)
|
||
|
||
- **Null test:** a dry offline capture of a range, re-inserted at its source
|
||
position, nulls to silence against the source. Ship this as a verification
|
||
action; it is the tool's trust anchor.
|
||
- **Bit-identical repeats:** identical offline requests produce identical files.
|
||
- **Non-destructive:** capture never mutates source items or tracks (realtime's
|
||
temp track is created and removed cleanly; source routing is restored).
|
||
- **Exact bounds:** no rounding of the requested range; no added silence unless a
|
||
tail is explicitly requested; channel count preserved (no silent stereo fold).
|
||
- **Relative paths only** in the persisted index.
|
||
|
||
## Non-goals / guardrails
|
||
|
||
- No auto-insertion of captures into the arrange (see the load-bearing principle).
|
||
- Native OS drag-out is deferred to the final milestone — `InsertMedia`-driven
|
||
placement is the primary path and must work first.
|
||
- Do not depend on REAPER's peak API for thumbnails; compute from the captured
|
||
file.
|
||
- Do not silently time-stretch on insert; conform is opt-in.
|
||
- Do not trust this brief's API names blindly — verify against the SDK header.
|
||
|
||
## Open questions to resolve during build
|
||
|
||
- Exact no-dialog render command/flag on the current REAPER build.
|
||
- Realtime record routing that captures wet master output without altering the
|
||
user's monitoring.
|
||
- Audio format for the bank (wav bit depth default; allow float for wavetable
|
||
fidelity).
|
||
- Thumbnail cache: recompute vs store peak bins alongside the index.
|
||
|
||
---
|
||
|
||
# Design View — additive phase spec
|
||
|
||
> **Additive section.** This is a standalone phase parallel to — not part of — the
|
||
> M0–M11 capture roadmap above. Nothing above changes. Product framing (workflow
|
||
> narrative, screenset differentiation, N-mode reasoning, design-direction calls)
|
||
> lives in `docs/product/design-view.md`; this section is the authoritative
|
||
> technical spec, matching the house style of the capture spec. Same standing
|
||
> discipline applies: **verify every REAPER API name/signature against
|
||
> `vendor/reaper-sdk/sdk/reaper_plugin_functions.h` before use.**
|
||
|
||
## What it is
|
||
|
||
A **track-visibility-plus-processing "mode" system**, toggled from the ReaSampler
|
||
window. Tracks used purely for sound design (scratch oscillators, FX mangling,
|
||
resampling sources) are tagged into **Design** mode; the arrangement's real tracks
|
||
are **Arrange** mode (the default). Toggling to a mode **hides and disables** the
|
||
tracks that don't belong to it. Workflow value: mental separation + clutter
|
||
elimination — be in Design view, resample into the bank, flip to Arrange, place it.
|
||
|
||
**This is not a separate canvas.** REAPER has exactly one arrange timeline. Design
|
||
View is the *same timeline* with a curated, filtered track set and the inactive
|
||
tracks parked — not a second surface, window, or duplicated project.
|
||
|
||
It is the visibility/processing analog of the capture pillar's load-bearing rule:
|
||
**designing and arranging are separate stances on one timeline**, and the tool
|
||
enforces the separation **without ever destroying the user's real state.**
|
||
|
||
## Settled decisions
|
||
|
||
- **Membership.** Default = Arrange; every untagged leaf belongs to it. Leaves
|
||
**opt in** to Design (or any mode). No track appears in two modes at once except
|
||
(a) via an explicit **show-both** toggle, or (b) parent/folder derivation.
|
||
- **Parents are derived, never tagged.** A parent/folder track is visible in mode M
|
||
if **either** (a) any descendant leaf is visible in M (descendant-derived), **or**
|
||
(b) the parent belongs to M by its **own membership** — and an untagged parent is
|
||
an Arrange member by default. So an untagged folder carrying its own FX/media
|
||
above all-Design leaves shows in **both** Arrange (its own default) and Design
|
||
(derived from its children). A parent is **never parked** in any mode it is visible
|
||
in. Rule of thumb: **tag leaves; parents follow** — the common case, since a
|
||
content-bearing folder still surfaces wherever its own membership places it.
|
||
Master track is always visible and never touched.
|
||
- **N-mode model, two-mode UI.** The data model carries arbitrarily many modes; the
|
||
UI ships **Arrange** + **Design**. A mode is (stable id, display name, ordinal).
|
||
Arrange is special only as the default home for untagged leaves.
|
||
- **Parking a track** (inactive-mode leaf): `B_SHOWINTCP=0`, `B_SHOWINMIXER=0`
|
||
(hide both panels), `B_MAINSEND=0` (out of mix), `I_FXEN=0` (FX bypassed), and
|
||
`TrackFX_SetOffline(track, fx, true)` for **each** FX (reclaim CPU). Full CPU-park
|
||
is the deliberate choice over mix-removal-only.
|
||
- **Never touches `B_MUTE` / `I_SOLO`.** The tool owns only visibility,
|
||
`B_MAINSEND`, `I_FXEN`, and per-FX offline — on every managed leaf, tagged or
|
||
untagged. User mute/solo survives every toggle untouched.
|
||
- **Persistence.** Membership index + last-active mode + per-track flag snapshots
|
||
ride in the existing `"reasampler"` project ext-state namespace and travel with
|
||
the `.rpp`. On project open, reapply the active mode's visibility + processing.
|
||
|
||
## Precision invariants (enforce, test)
|
||
|
||
- **Non-destructive restore.** For every flag the tool drives, snapshot the prior
|
||
value **before** parking; on toggle-back restore **from the snapshot**, never to a
|
||
hardcoded "on." Round-trip (snapshot → park → restore) returns every driven flag
|
||
to its captured value. This is the phase's trust anchor — the analog of the
|
||
capture null test — and is enforced in the pure layer.
|
||
- **Mute/solo untouched.** No toggle ever reads or writes `B_MUTE` / `I_SOLO`.
|
||
- **Master untouched.** The tool never drives the master track's visibility flags
|
||
(the SDK forbids `B_SHOWINTCP`/`B_SHOWINMIXER` on master; the invariant agrees).
|
||
- **GUID-keyed, reorder-safe.** Membership keys on track GUID (`GetTrackGUID`),
|
||
never track index; tolerates unknown/stale GUIDs (prune on reconcile).
|
||
- **Relative/portable state only** in the persisted view section (GUID strings, mode
|
||
ids — no absolute paths, no index positions).
|
||
|
||
## Documented caveat
|
||
|
||
Offlined FX **re-instantiate** when a track returns to the active mode. Stateful
|
||
plugins (convolution, loaded samplers, tail-holding effects) re-initialize on
|
||
return — possible load hitch, un-persisted internal state lost. Accepted cost of
|
||
the CPU reclaim; surface it at the toggle affordance (tooltip).
|
||
|
||
## Module architecture (preserve the pure/shell split)
|
||
|
||
Pure (no REAPER types, unit-tested — the mirror of `bank_model`):
|
||
- `view_mode_model` — mode registry (id/name/ordinal; Arrange + Design seeded);
|
||
membership index (`track GUID → { mode ids }` + per-track show-both flag; add /
|
||
remove / retag / query); **folder-tree-aware** visibility derivation (given the
|
||
current parent↔child tree supplied by the shell + the active mode, compute the
|
||
visible set); the **parking/restore planner** (given active mode + snapshot
|
||
record, emit the exact (track, flag, value) operation lists for park and
|
||
restore — where the restore invariant is enforced); JSON round-trip of modes +
|
||
membership + show-both + snapshots + active mode.
|
||
|
||
REAPER-facing:
|
||
- `view` shell — reads `I_FOLDERDEPTH` across the track list to build the
|
||
parent↔child tree and feeds it to `view_mode_model`; applies the planner's operations
|
||
via `SetMediaTrackInfo_Value` (`B_SHOWINTCP` / `B_SHOWINMIXER` / `B_MAINSEND` /
|
||
`I_FXEN`) and `TrackFX_GetCount` + per-FX `TrackFX_SetOffline`; snapshots prior
|
||
flag values before parking; resolves GUIDs via
|
||
`GetTrackGUID` / `guidToString` / `stringToGuid`. Never touches master
|
||
visibility, never touches `B_MUTE` / `I_SOLO`.
|
||
- `persist` (slice) — serialize/deserialize the view section into the
|
||
`"reasampler"` namespace alongside the bank; on project open, rebuild the tree
|
||
and reapply the active mode.
|
||
- `actions` (entries) — toggle active mode; activate mode: Arrange / Design;
|
||
tag/untag selected tracks → mode; show-both for selected tracks. Registered with
|
||
the `command_id` / `gaccel` / `hookcommand` pattern; toggle + mode-jumps
|
||
MIDI-bindable.
|
||
- UI (in the ReaSampler / bank_panel window) — a **segmented mode switch**
|
||
(`[ Arrange | Design ]`) in the window header, active segment lit; small per-mode
|
||
membership count; the offlined-FX caveat as a tooltip. Tag/untag acts on the
|
||
current REAPER track selection, not a per-track widget.
|
||
|
||
## Show-both semantics
|
||
|
||
A **per-track "pin visible across modes"** flag that **re-enables processing**
|
||
whenever shown. A show-both leaf appears in every mode's visible set and is **never
|
||
parked** — its driven flags stay at snapshot/restored values, FX online, in the
|
||
mix. ("Show but keep parked" is not offered — a visible-but-silent-and-offline
|
||
track is clutter with a thumbnail.) Stored on the membership record; persists;
|
||
togglable per selection.
|
||
|
||
## REAPER API surface (verify all signatures)
|
||
|
||
- Visibility/routing/FX flags via `GetMediaTrackInfo_Value` (snapshot) /
|
||
`SetMediaTrackInfo_Value` (apply): `B_SHOWINTCP`, `B_SHOWINMIXER`, `B_MAINSEND`,
|
||
`I_FXEN`. (Note: brief cited `B_SHOWINMCP`; verified SDK name is `B_SHOWINMIXER`.)
|
||
- Per-FX offline: `TrackFX_GetCount` + `TrackFX_SetOffline(track, fx, offline)`.
|
||
- Folder tree: read `I_FOLDERDEPTH` per track to derive parent↔child structure.
|
||
- GUID keying: `GetTrackGUID`, `guidToString`, `stringToGuid`.
|
||
- Persistence: `SetProjExtState` / `GetProjExtState` under `"reasampler"` (shared
|
||
with the bank index — one blob, two logical sections).
|
||
- Wrap flag mutations in `Undo_BeginBlock2` / `Undo_EndBlock2` as appropriate.
|
||
|
||
## Non-goals / guardrails
|
||
|
||
- **No second canvas.** Do not build a parallel arrange surface — reject any such
|
||
path in review.
|
||
- **Never touch mute/solo.** Any code path reading/writing `B_MUTE` / `I_SOLO` is a
|
||
bug.
|
||
- **Every leaf is managed.** The mode system owns all leaf tracks: an untagged leaf
|
||
is an Arrange member, and when the active mode is not Arrange it is fully parked
|
||
and snapshot-restored exactly like a tagged leaf out of its mode. **show-both** is
|
||
the only way to opt a leaf out of parking. (Parents stay visibility-only, master
|
||
is never touched — see below.)
|
||
- **Restore from snapshot, never to a default.** No hardcoded "on" restores.
|
||
- **Verify API names** against the SDK header before use.
|
||
|
||
## Open questions to resolve during build
|
||
|
||
- Snapshot durability across a save-while-parked (persisted here by decision; the
|
||
lean alternative is force-restore-to-Arrange on save — see product notes item 4).
|
||
- Reconcile behavior when a tagged leaf is deleted or a folder restructured while
|
||
parked (ignore-and-prune stale GUIDs on next toggle/open).
|
||
- Interaction with the user having a screenset active (Design View drives the same
|
||
flags a screenset recall would; last writer wins — confirm no surprising fight).
|
||
|
||
---
|
||
|
||
# Multi-bank — additive phase spec
|
||
|
||
> **Additive section.** This is a standalone phase parallel to — not part of — the
|
||
> 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.
|