e4bcc8f075
Persisted gap-preserving per-bank display order + drag-drop reorder (model change, JSON round-trip + migration), decorative bars.beats/s.ms overlay, tertiary-border selection restyle. Model work separated from draw work; forks flagged (meter source, M9 substrate overlap, drag disambiguation).
2080 lines
135 KiB
Markdown
2080 lines
135 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` (capture scope — item or
|
||
track, time range, tail, SR/bit-depth/channels, output path). Output: a finished
|
||
file + a populated `Sample` handed to `bank_model`. Capture is always wet; the FX
|
||
*scope* (not a wet/dry dial) is the control — the pure `render_settings` module
|
||
maps a scope to its render-source bits and its FX-bypass plan (see §Capture FX
|
||
scope below).
|
||
- `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`) and
|
||
`GetSetProjectInfo_String` (`RENDER_FILE`, `RENDER_PATTERN`, `RENDER_FORMAT`).
|
||
The `RENDER_SETTINGS` bit choice is driven by capture scope: **item** scope renders
|
||
selected media items (single-file), **track** scope renders selected tracks via
|
||
master. There is **no master-mix scope** (to capture the master, render a track).
|
||
Render is always wet — REAPER has no true pre-FX "dry" render bit; FX scoping is done
|
||
by the FX-bypass-around-render mechanism, not a render bit (see §Capture FX scope).
|
||
- 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 (**track scope** — item realtime is deferred):
|
||
- Taps the **selected track**, not the master. Recipe: create a hidden temp track,
|
||
route a **post-fader send from each selected source track into it**
|
||
(`CreateTrackSend`, default post-fader/full-stereo — captures each track's own
|
||
output *before* the parent/master sums it, so the tap is chain-independent by
|
||
construction and needs no FX bypass), set the temp's record mode to record-output
|
||
(latency-compensated, `B_MAINSEND=0` so it does not sum back), arm (`I_RECARM`),
|
||
`CSurf_OnRecord`, run for the range, `OnStopButtonEx`, then move the recorded file
|
||
into the bank and delete the temp track (which drops the sends — no source track is
|
||
left mutated). Verify `I_RECMODE` values for output-recording.
|
||
- Master→track sends are refused by REAPER as feedback loops — this is *why* the tap
|
||
is the selected track's post-fader output, not the master.
|
||
|
||
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: two FX scopes — **item** (item/take FX only) and **track**
|
||
(item FX + the selected track's own track FX), each chain-independent (the
|
||
out-of-scope chain is neutralized to unity for the render). No master scope — to
|
||
capture the master, render a track. Each scope captures over a **time-or-razor
|
||
range** (razor is a range source, not a mode: razor-when-present, else time
|
||
selection). Capture is always wet; the scope is the control. All registered as
|
||
bindable actions (tail off; a tail-on variant is a later opt-in).
|
||
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. (Verification action cut per
|
||
`docs/product/provenance.md` — manual verification only.)
|
||
- **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.~~ **Resolved:** realtime taps the *selected track* (track scope),
|
||
not the master — a post-fader track→temp send is chain-independent by construction,
|
||
so there is no wet-master-routing problem and monitoring is untouched (see the
|
||
Realtime record API surface above).
|
||
- 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.
|
||
|
||
Design View is a **mode projection over REAPER's single arrange timeline** —
|
||
reaching both **tracks** (parked per mode) and **items** (lane-split per mode on
|
||
shared tracks). It approximates two canvases without a second surface: same project,
|
||
same timeline, but each stance sees its own tracks and its own items. It **never**
|
||
duplicates the project or opens a second window.
|
||
|
||
> **Two-canvas reach — settled (2026-07-23).** The original single-canvas framing
|
||
> here was derived from a REAPER constraint, not chosen as a product stance. Daniel
|
||
> reopened it — "as close as possible to two separate design and arrange canvases,
|
||
> same project, different items and leaves" — and has now signed off on the
|
||
> mechanism and resolved all forks. The "different leaves" half is delivered by
|
||
> track-parking (D1). The "different items" half is delivered by **per-item mode
|
||
> membership via REAPER fixed lanes** (track-side `I_FREEMODE=2`, `I_NUMFIXEDLANES`,
|
||
> `C_LANEPLAYS:N`; item-side `I_FIXEDLANE`, `C_LANEPLAYS`, `B_FIXEDLANE_HIDDEN`):
|
||
> each mode owns a lane on a shared track, and toggling shows/plays only the active
|
||
> mode's lane. This is an item-visibility projection over the one timeline — the
|
||
> exact analog of today's track-visibility projection — with still **no** literal
|
||
> second surface, window, or duplicate project. It is an **additive sub-phase (Phase
|
||
> D2 / Phase E)** on top of D1, spec'd in *§Two-canvas sub-phase* below. Full framing
|
||
> and the rejected alternatives (timebase-offset, subproject) live in
|
||
> `docs/product/design-view.md` §Two-canvas direction.
|
||
|
||
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 *literal* second canvas.** A literal second arrange surface, a second window,
|
||
or a duplicated project stays **rejected** — reject any such path in review. This
|
||
guardrail was **narrowed (2026-07-23), not lifted**: per-item mode separation via
|
||
REAPER fixed lanes on shared tracks (an item-visibility projection over the one
|
||
timeline) is now **allowed** and specified in *§Two-canvas sub-phase* below. Paths
|
||
that remain **rejected**: subproject / second-project-file approaches, and
|
||
overloading item `D_POSITION` with mode semantics (timebase-offset regions) — the
|
||
latter collides with the capture null test. See `docs/product/design-view.md`
|
||
§Two-canvas direction for why those were rejected.
|
||
- **Never touch mute/solo.** Any code path reading/writing `B_MUTE` / `I_SOLO` is a
|
||
bug.
|
||
- **Never drive a manual lane** (Phase D2). A mode toggle touches only **managed**
|
||
lanes (minted by the mode system, keyed in the lane-ownership index). Any code path
|
||
that shows, hides, silences, or re-lanes a **manual** lane — a user-created comp/take
|
||
lane outside the mode system — is a bug. The tool drives only lanes it created.
|
||
- **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
|
||
|
||
- Reconcile residuals (bulk behavior shipped — `ViewModeModel::reconcile(liveGuids)`
|
||
prunes orphaned snapshots on every toggle/load; folder restructure is self-healing
|
||
because the tree is rebuilt each toggle; membership is intentionally kept so
|
||
undo-delete preserves the tag). Two sub-items remain deferred:
|
||
- **FX-GUID keying for `restoreFxOffline`:** currently restores by slot index; a
|
||
reshuffled FX chain while parked will restore to the wrong slot. Fix requires
|
||
FX-GUID keying + snapshot-schema migration — deferred.
|
||
- **Dormant membership entries:** truly-deleted tracks accumulate stale entries in
|
||
persisted `view_state` (harmless and bounded); natural home is a future
|
||
user-initiated "compact" action, not automatic pruning (which would reintroduce
|
||
undo-delete tag-loss).
|
||
- 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).
|
||
|
||
## Two-canvas sub-phase (Phase D2 / Phase E) — additive to D1
|
||
|
||
> **Additive sub-phase, settled 2026-07-23.** Extends D1's track-level mode
|
||
> projection to **item level** so each stance owns its own items as well as its own
|
||
> tracks. Nothing in D1 changes; this wraps it. Runtime floor rises to **REAPER 7**
|
||
> for this sub-phase (fixed lanes shipped in v7). Product framing in
|
||
> `docs/product/design-view.md` §Two-canvas direction.
|
||
|
||
### What it adds
|
||
|
||
On a track present in **both** stances (a show-both track, or a folder carrying its
|
||
own media), each mode owns a **fixed lane**: the active mode's lane shows and plays;
|
||
the inactive mode's lane is hidden and silent. A Design take and an Arrange take can
|
||
then live on the *same track, same time position*, without colliding on the view.
|
||
Track-only-in-one-mode content is still handled by D1 track-parking, unchanged.
|
||
|
||
### Settled decisions
|
||
|
||
- **Mechanism: fixed item lanes.** Map mode → lane; toggle drives per-lane play/show
|
||
so only the active mode's lane is present. Items keep their real position and real
|
||
track — nothing is moved in time or deleted. SDK surface (**verified present in
|
||
`vendor/reaper-sdk`**): track-side `I_FREEMODE = 2`, `I_NUMFIXEDLANES`,
|
||
`C_LANEPLAYS:N`; item-side `I_FIXEDLANE`, `C_LANEPLAYS`, `B_FIXEDLANE_HIDDEN`.
|
||
**`I_FREEMODE` changes require `UpdateTimeline()`** to take visible effect.
|
||
- **Membership: auto-tag by active mode at creation.** New content — **both new
|
||
tracks and new items** — is tagged to whatever mode is active when it is created.
|
||
Pre-existing content defaults to **Arrange**. Membership is **exclusive**:
|
||
Design-created content never appears in Arrange and vice versa, except via the
|
||
existing **show-both** escape hatch. (This is the same tag-to-active-mode rule as
|
||
D1 track membership, now reaching items on shared tracks.)
|
||
- **Inactive-mode content is hidden AND silenced.** The off-mode lane is set
|
||
`C_LANEPLAYS = 0` — neither shown nor played — consistent with exclusive
|
||
membership and with D1's "flipping modes is a real change, not cosmetic." Show-both
|
||
is the deliberate opt-out for a lane that must stay audible across modes.
|
||
- **Managed vs. manual lanes — indexed and distinct.** Fixed lanes are also REAPER's
|
||
native comping surface: a user may keep **their own** manual lanes (comp takes,
|
||
alternate reads) on a track alongside the mode system's lanes. The tool maintains a
|
||
**lane-ownership index** — per (track GUID, lane): **managed** (which mode owns it)
|
||
vs **manual** (user-minted, outside the mode system). Mode operations touch **only
|
||
managed lanes**; manual lanes are never shown, hidden, silenced, or re-laned by a
|
||
toggle, and their `C_LANEPLAYS` stays exactly as the user set it. Items a user adds
|
||
to a **manual** lane are **not** auto-tagged (auto-tag governs normal timeline
|
||
content, not hand-managed lanes). The ownership index rides in `"reasampler"`
|
||
`view_state` alongside the membership index, GUID-keyed and portable.
|
||
- **Capture placement is mode-aware.** An **explicit** placement while in Design mode
|
||
— including capture-and-place — lands the item in the **Design lane**; the same
|
||
rule governs manual insertion. **The capture load-bearing principle is untouched:**
|
||
capture still writes a file + index entry and **never** auto-inserts; this governs
|
||
only *where an explicit placement lands*.
|
||
- **REAPER floor: v7.** No version-gate branch — below v7 this sub-phase is simply
|
||
unavailable.
|
||
|
||
### Precision invariants (unaffected — called out explicitly)
|
||
|
||
The capture precision invariants — **null test, bit-identical repeats,
|
||
non-destructive, exact bounds, relative-paths-only** — are **entirely unaffected** by
|
||
this sub-phase. No capture path changes; lanes are a placement/view concern
|
||
downstream of the written file. Lane assignment and `C_LANEPLAYS` are **reversible
|
||
flags**: the item is never relocated in time or deleted, so the sub-phase extends the
|
||
D1 non-destructive snapshot/restore contract to a new (item-lane) flag family rather
|
||
than introducing any destructive operation.
|
||
|
||
**New invariant — mode operations touch only managed lanes.** A mode toggle drives
|
||
only lanes the mode system minted (managed lanes, keyed in the lane-ownership index).
|
||
Manual lanes — user-created comp/take lanes outside the mode system — are **never**
|
||
shown, hidden, silenced, or re-laned by a toggle; their `C_LANEPLAYS` is left exactly
|
||
as the user set it. This is the fixed-lane analog of *never touch `B_MUTE`/`I_SOLO`*
|
||
and *never touch master*: **the tool drives only what it created.** Enforceable and
|
||
testable in the pure layer — the "which lanes may this toggle touch" decision is a
|
||
pure query over the ownership index; only reading REAPER's live lane state is shell.
|
||
|
||
### Module architecture (preserve the pure/shell split)
|
||
|
||
- **Pure (`view_mode_model` extension).** Lane math — which lane maps to which mode,
|
||
which `C_LANEPLAYS` value per mode, the item-lane op family alongside the existing
|
||
track-flag op family — is **REAPER-free and unit-tested**, mirroring the D1
|
||
planner. The **lane-ownership index** (per (track GUID, lane): managed-which-mode vs
|
||
manual) and the **"which lanes may this toggle touch" query** (managed only) are
|
||
pure and unit-tested — the planner emits lane ops for managed lanes only and never
|
||
for manual lanes. The **auto-tag decision** is pure too: given *a set of new
|
||
track/item GUIDs + the active mode* — and, for items, whether the item landed in a
|
||
manual lane (exempt) — produce the membership writes.
|
||
- **Shell.** Two shell responsibilities. (1) **Apply** the planner's item-lane ops
|
||
(`I_FREEMODE`/`I_FIXEDLANE`/`C_LANEPLAYS`/`B_FIXEDLANE_HIDDEN` via the media-item
|
||
info setters, `UpdateTimeline()` after `I_FREEMODE` changes) — for managed lanes
|
||
only. (2) **Detect** new content and read live lane state — see below.
|
||
- **Persistence.** The tool persists which lane maps to which mode **and the
|
||
managed/manual ownership index** (a small addition to the `"reasampler"` view
|
||
section); REAPER stores fixed lanes and lane-plays in the `.rpp` natively.
|
||
|
||
### New-content detection (implementation design point)
|
||
|
||
REAPER exposes **no clean "item added" / "track added" event callback.** Auto-tagging
|
||
therefore requires the shell to **diff project state on the panel's existing timer** —
|
||
the `bank_panel` already polls and fingerprints the bank; this extends that machinery
|
||
to the timeline's tracks and items.
|
||
|
||
- Each poll, compare the live track/item GUID set against the previous poll's set;
|
||
**any GUID new since the last poll is tagged to the then-active mode.**
|
||
- Correctness the implementation must handle: the **first poll after project open must
|
||
not mass-tag** pre-existing content (pre-existing defaults to Arrange, per the
|
||
membership rule).
|
||
- **Manual-lane exemption (design point).** An item added to a **manual** lane is
|
||
**not** auto-tagged — auto-tag governs normal timeline content, not lanes the user
|
||
hand-manages. Distinguishing the two may need a heuristic at detection (e.g., an item
|
||
whose `I_FIXEDLANE` is marked manual in the ownership index is exempt; content
|
||
outside any managed lane on a mode-managed track follows the active-mode rule). The
|
||
precise rule is an **open implementation design point**; the settled boundary is that
|
||
manual-lane content is off-limits to auto-tag.
|
||
- **Lane-identity fragility (design point).** `I_FIXEDLANE` is the lane's identity and
|
||
is how the ownership index keys to a lane. Whether the index survives lane
|
||
reorder/renumber/deletion without going stale is an **implementation design point**
|
||
(same class as GUID-keyed reorder-safety for tracks) — flag, don't solve here.
|
||
- Pure/shell seam: the **detection** (diffing REAPER's live set each tick, reading live
|
||
lane ownership) is shell; the **tagging decision** and the **managed/manual lane
|
||
query** (new GUIDs + active mode + manual-lane exemption ⇒ membership + lane writes)
|
||
are pure and unit-tested.
|
||
|
||
### REAPER API surface (verify all signatures)
|
||
|
||
- Fixed lanes — track: `I_FREEMODE` (=2), `I_NUMFIXEDLANES`, `C_LANEPLAYS:N` via
|
||
`GetMediaTrackInfo_Value`/`SetMediaTrackInfo_Value`; item: `I_FIXEDLANE`,
|
||
`C_LANEPLAYS`, `B_FIXEDLANE_HIDDEN` via `GetMediaItemInfo_Value` /
|
||
`SetMediaItemInfo_Value`. Call `UpdateTimeline()` after `I_FREEMODE` changes.
|
||
- Detection reuses the `bank_panel` timer + GUID fingerprinting already in place;
|
||
item GUIDs via the item's `GUID` (`GetSetMediaItemInfo_String` `"GUID"`), track
|
||
GUIDs via `GetTrackGUID` as in D1.
|
||
- **Verify every name/signature against the SDK header before use** — the surface is
|
||
verified *present*, but confirm argument order, types, and flag values.
|
||
|
||
---
|
||
|
||
# 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). **Display names are unique**, enforced in the
|
||
pure model on create and rename: `createBank` / `renameBank` reject a name that
|
||
duplicates an existing bank's (renaming a bank to its own current name is a no-op
|
||
success). The comparison is **trimmed + case-insensitive (ASCII)**, so "Drums",
|
||
"drums", and " Drums " cannot coexist; the pool's reserved name "Pool" is protected
|
||
by the same check. Uniqueness makes by-name resolution in the action shell
|
||
unambiguous by construction. 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.** 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 is the default.** *Move* removes from
|
||
source, adds to destination (one logical sample, regrouped) — it is the **primary,
|
||
low-friction gesture**, so a sample lives in exactly one bank at a time. *Copy* adds
|
||
to destination and leaves the source entry intact (same file, two index entries, two
|
||
banks) — the **deliberate secondary act** for the "in two places at once" case. Both
|
||
are index-only; both share the destination-collapse rule. Under move-as-default the
|
||
pool is the default home and staging ground, not a permanent superset: moving a
|
||
sample into a named bank takes it out of the pool. (See product notes for the
|
||
mental-model reconciliation.)
|
||
- **Delete drops members; evacuate returns them.** Deleting a **named** bank drops
|
||
its member index entries (files are **not** deleted — file lifecycle stays owned by
|
||
the capture/prune path). A separate **evacuate** operation moves all of a bank's
|
||
members back to the pool (index-only, same destination-collapse-by-hash as move),
|
||
leaving the bank empty. Intended workflow: *evacuate then delete* to keep the
|
||
samples, *plain delete* to drop the grouping and its members. A plain delete of a
|
||
non-empty bank orphans those members out of every index — their files persist on
|
||
disk until prune, referenced by no bank — so the UI **confirms on non-empty delete**
|
||
and offers evacuate as the alternative. Evacuate cannot be applied to the pool.
|
||
- **Persistence: a new ext-state key; the pool folds in and the legacy key is
|
||
retired.** The multi-bank state serializes to a new key `banks` in the existing
|
||
`"reasampler"` namespace, alongside `view_state` and `project_guid`. The pool's
|
||
index rides *inside* the `banks` blob as bank-zero — persisted identically to any
|
||
named bank (one blob, one section, one JSON shape). **Migration:** on load, if a
|
||
`banks` key is absent but a legacy `bank_index` key is present, the legacy index is
|
||
promoted into the pool inside a freshly-minted `banks` blob and the book is
|
||
`{ pool }` with zero named banks — a one-way, lossless promotion. After migration
|
||
the `banks` blob is **authoritative**; the legacy `bank_index` key is **retired** (not
|
||
written or read back going forward). The one-way retirement trades pre-multi-bank
|
||
backward-read compatibility for the clean single-blob shape — an accepted,
|
||
forward-only migration consistent with how M4 project state already moves forward.
|
||
- **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 / evacuate 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 drops its member index
|
||
entries but does **not** delete their files; file lifecycle stays owned by the
|
||
capture/prune path, not the bank container. A file referenced *only* by the deleted
|
||
bank becomes an orphan on disk — present but indexed by no bank — until the
|
||
capture/prune path reclaims it. That orphaned-until-prune window is designed, not
|
||
accidental; the *evacuate* verb and the confirm-on-non-empty-delete guardrail exist
|
||
to keep the user out of it unintentionally.
|
||
- **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; delete drops member index entries; **display names unique** — create/rename
|
||
reject a name that duplicates another bank's, trimmed + case-insensitive, "Pool"
|
||
protected); **evacuate** a bank (move every member to
|
||
the pool, index-only, destination-collapse observed; pool cannot be evacuated);
|
||
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 (pool-as-bank-zero inside the blob + named banks +
|
||
per-bank indices + active id + ordinals) and legacy-`bank_index`→pool migration on
|
||
parse (one-way; blob authoritative thereafter).
|
||
|
||
REAPER-facing:
|
||
- `persist` (slice) — serialize/deserialize the book under the `banks` key in
|
||
`"reasampler"` (pool-as-bank-zero inside the blob; no separate `bank_index` key
|
||
going forward); migrate a legacy `bank_index` key into the pool on first load
|
||
(one-way; blob authoritative thereafter, legacy key retired); 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. The named-banks tab strip is
|
||
**LICE-drawn** to match the M5 grid and the Design View segmented switch (not a
|
||
SWELL-native tab control), with an **overflow/scroll affordance** so it scales past
|
||
the ~8–12-tab point. Sample move ships **both ways**: a "move to bank" menu on the
|
||
current selection (the bindable front-end for the B3 move action) and
|
||
drag-between-regions (the direct-manipulation accelerator); copy is the deliberate
|
||
secondary act, offered on the menu. Drag carries clear drop-target highlighting on
|
||
the destination region/tab, and a mis-drop is recoverable by design (move is
|
||
index-only and reversible — the user moves the sample back). Reuses the existing
|
||
LICE grid render loop per bank region.
|
||
- `actions` (entries) — create bank / rename bank / delete bank (confirm on
|
||
non-empty delete); evacuate bank → pool; 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 + evacuate 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 **LICE-drawn** tab strip (with overflow/scroll) +
|
||
toggles. The tab strip, the toggle affordances, and the drag hit-testing are
|
||
custom-drawn on the M5 LICE surface (not SWELL-native tabs); the "move to bank" menu
|
||
uses a SWELL popup-menu surface. **Verify LICE drawing and any SWELL menu/drag
|
||
hit-test usage against the M5 reference / SWELL headers**, and confirm the drag
|
||
hit-test does not collide with the M5 grid's multi-select drag. 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,
|
||
leave a project with zero banks, or **evacuate** the pool (the pool is evacuation's
|
||
destination, not a source). Enforce in the pure rules layer, not just the UI.
|
||
- **Delete drops members; files are never deleted by a bank op.** Deleting a named
|
||
bank removes its member index entries only. No bank operation writes, moves, or
|
||
deletes a file — file lifecycle stays with capture/prune. The UI **confirms on
|
||
non-empty delete** and offers evacuate; do not silently orphan members.
|
||
- **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
|
||
|
||
Forks 1–5 are all settled (see product notes → *Settled forks* and *Fork 5 — settled*,
|
||
and the settled-decision prose above). One panel-polish detail remains open.
|
||
|
||
- **Fork 5 — tab rendering + move affordance (B4). Settled (2026-07-23).** (5a) The
|
||
named-banks region is **LICE-drawn** to match the M5 grid and the Design View
|
||
segmented switch — not a SWELL-native tab control — with an **overflow/scroll
|
||
affordance in scope from the start** so the strip scales past the ~8–12-tab
|
||
breakdown. (5b) Move ships as **both** a "move to bank" menu (the precise,
|
||
MIDI-bindable front-end for the B3 move action) **and** drag-between-regions (the
|
||
direct-manipulation accelerator); copy stays the deliberate secondary act via the
|
||
menu. Drag mis-drop is mitigated by drop-target highlighting and is recoverable by
|
||
design (move is index-only and reversible). Folded into the `bank_panel` prose and
|
||
the API surface below. **Verify LICE tab drawing and any SWELL menu/drag hit-test
|
||
surface against the M5 reference / SWELL headers before use** (confirm no collision
|
||
with the M5 grid's multi-select drag). Analysis in product notes → *Fork 5*.
|
||
- **Active-bank indicator placement (B4 polish)** — per-region headers vs. a single
|
||
header readout vs. lit-tab treatment. The "visually unmistakable" requirement is
|
||
settled (fork 4); only the placement is open. Panel-polish detail.
|
||
|
||
---
|
||
|
||
# Sample removal — additive spec (Phase B, point B5)
|
||
|
||
> **Additive section, part of the Multi-bank pillar.** The sample-level companion
|
||
> to move/copy/evacuate/delete-bank: a verb that **drops a `Sample`'s index entry**
|
||
> from a bank (or the pool). Index-only, non-destructive to the file — it sits on
|
||
> the same side of the index/file line as every other Phase B op. Product framing:
|
||
> `docs/product/removal-and-prune.md` §Sample-remove. Same verify discipline:
|
||
> **verify every REAPER API name/signature against the SDK header before use.**
|
||
|
||
## What it is
|
||
|
||
Move, copy, and evacuate all keep a sample *somewhere*; there was no verb to drop
|
||
a sample outright. **Sample-remove** is that verb: it removes one `Sample` entry
|
||
from one `BankIndex`. It exposes the `remove` primitive `bank_model`'s `BankIndex`
|
||
**already has** — B5 wires it to an action + a panel affordance, it does not add a
|
||
model capability.
|
||
|
||
## Settled decisions (spec-level)
|
||
|
||
- **Remove is index-only.** It removes the `Sample` from a `BankIndex` and mutates
|
||
only index + ext-state. No file is written, moved, or deleted; no timeline item
|
||
is touched. Identical non-destructive posture to move/copy/evacuate/delete-bank.
|
||
- **Remove can orphan a file — the same designed orphaned-until-prune state a
|
||
non-empty delete-bank produces.** When remove drops the *last* index reference to
|
||
a file (no other bank holds its hash), that file becomes an orphan on disk,
|
||
referenced by no bank, reclaimed later by **prune** (Phase R) — never by remove.
|
||
This is not a new hazard class; it is the existing "files persist until prune"
|
||
window, reached by a sample-level verb instead of a bank-level one.
|
||
- **Collapse-by-hash is unaffected.** Remove targets a specific entry in a specific
|
||
bank. Because cross-bank dedup is deliberately not enforced, removing a sample
|
||
from one bank leaves any same-hash entry in another bank intact — the same
|
||
coexistence copy relies on.
|
||
- **The pool's *contents* are removable; the pool *container* is not.** Pool
|
||
privileges (un-deletable, un-renamable, un-evacuable) govern the pool as a
|
||
container. Individual samples **can** be removed from the pool — otherwise the
|
||
pool would be a one-way trap. Remove-from-pool is the pool's own "drop this
|
||
sample" verb and is allowed.
|
||
- **Remove scope (fork R-A, SETTLED 2026-07-24 — this-bank).** Remove drops the
|
||
entry from *this* bank only, leaving copies in other banks untouched — the core
|
||
and only shipped verb. The action carries a `scope: this-bank | all-banks` seam,
|
||
but **this-bank is the settled default and the only surfaced affordance**;
|
||
all-banks stays a latent parameter (promotable later behind the seam without a
|
||
rewrite), never a surfaced verb now. See product notes §Fork R-A.
|
||
|
||
## Precision / invariant implications
|
||
|
||
- **Non-destructive** extends to remove verbatim: index + ext-state only, no file
|
||
touched, no timeline item touched.
|
||
- **Relative-paths-only** is unaffected — remove deletes an entry, it adds no path
|
||
handling.
|
||
- **Determinism / bit-identical / null-test (capture)** untouched — remove sits
|
||
above the file, same as all of multi-bank.
|
||
|
||
## Guardrails
|
||
|
||
- **Removes are silent — no confirm dialog.** Recoverability is provided by the
|
||
batched REAPER undo (R-B): one Ctrl-Z restores the index entry, whether or not
|
||
the sample was a last reference. Files are never deleted by remove
|
||
(orphaned-until-prune is unchanged). `hashReferencedElsewhere` is a tested
|
||
model API retained for Phase R prune; it has no shell caller in the remove path.
|
||
- **Undo (fork R-B, SETTLED 2026-07-24 — batched REAPER undo points, Phase-B-wide).**
|
||
Bank/index mutations integrate into REAPER's undo system as **batched undo points**
|
||
(`Undo_BeginBlock` / `Undo_EndBlock`): the related index mutations of one bank
|
||
operation are batched into a single undo point, so one bank operation is one
|
||
Ctrl-Z. This is a **Phase-B-wide** decision — it applies to
|
||
create/rename/reorder/delete-bank, move, copy, evacuate, *and* remove, retro-
|
||
touching B1–B4, not just B5. **Must-verify before build:** confirm against
|
||
`vendor/reaper-sdk` that `"reasampler"` ext-state mutations participate correctly
|
||
in `Undo_BeginBlock`/`Undo_EndBlock` undo blocks — the whole approach depends on
|
||
it. Surfaced with remove because remove is the first verb whose *only* effect is
|
||
index-entry destruction with no relocation, so it is where the gap first bit; the
|
||
fix is shared. See product notes §Fork R-B.
|
||
|
||
## Module architecture (preserve the pure/shell split)
|
||
|
||
- `bank_book` / `BankIndex` (pure) — expose remove of a `Sample` from a bank's
|
||
index (the existing `BankIndex::remove` primitive, surfaced through the book);
|
||
pool contents removable, pool-container privileges unchanged.
|
||
- `actions` (entry) — "remove selected sample(s) from bank" (and, under fork R-A,
|
||
a scope parameter); registered with the `command_id`/`gaccel`/`hookcommand`
|
||
contract; MIDI-bindable to suit the capture-heavy workflow.
|
||
- `bank_panel` (affordance) — remove on the current selection (menu entry / key),
|
||
reusing the M5 selection model exactly as move/copy do; confirm-on-last-reference
|
||
at this layer.
|
||
|
||
## REAPER API surface
|
||
|
||
No new REAPER API. Pure model + a new action command-id string under the sampler
|
||
family prefix + a panel affordance on the existing M5 LICE surface. Verify the
|
||
command-id/gaccel/hookcommand usage against `main.cpp` (unchanged contract).
|
||
|
||
## Settled forks (Daniel, 2026-07-24)
|
||
|
||
- **Fork R-A — remove scope.** Settled: **this-bank** (this-bank-primary, all-banks
|
||
a latent seam-only parameter). Folded into Settled decisions above.
|
||
- **Fork R-B — undo model for index mutations.** Settled: **batched REAPER undo
|
||
points** (`Undo_BeginBlock`/`Undo_EndBlock`), Phase-B-wide (retro-touches B1–B4),
|
||
with the ext-state-participation SDK check as a must-verify-before-build. Folded
|
||
into Guardrails above and the Phase B / B1 plan points.
|
||
|
||
---
|
||
|
||
# Prune — file-lifecycle spec (Phase R — Reclaim)
|
||
|
||
> **New pillar, its own lettered phase.** Prune is the file-lifecycle path the
|
||
> capture and multi-bank specs forward-reference throughout ("files persist on disk
|
||
> until prune", "the capture/prune path reclaims it") but that had no phase, module,
|
||
> or point until now. It is the **only** operation in ReaSampler that deletes bytes
|
||
> off disk. Namespaced **`R` (Reclaim)** alongside `M` (capture), `D` (Design View),
|
||
> `B` (Banks) — it is a distinct pillar, not a Multi-bank sub-step, because it
|
||
> serves *every* orphan-producing path (delete-bank, sample-remove, re-capture) and
|
||
> carries a new risk class (file deletion) with its own invariants. Product framing
|
||
> and the phase-placement justification: `docs/product/removal-and-prune.md` §Prune.
|
||
> Same discipline: **verify every REAPER/SWELL/filesystem API name/signature against
|
||
> the SDK/SWELL headers before use.**
|
||
|
||
## What it is
|
||
|
||
Over a project's life, delete-bank and sample-remove (and, potentially, M10
|
||
re-capture superseding an old file) leave `.wav` files on disk that no bank index
|
||
references — the "orphaned-until-prune" state the specs design in on purpose.
|
||
**Prune is the reclaim pass**: reconcile the physical bank folder against the union
|
||
of every bank's index, and reclaim the files nothing references. It makes good on
|
||
the promise the rest of the spec keeps making.
|
||
|
||
## The load-bearing rule
|
||
|
||
> **Remove creates orphans; prune reclaims them.** Sample-remove and delete-bank
|
||
> drop index entries and may leave a file referenced by nothing. Prune is the
|
||
> single path that turns such an orphan back into free disk space. **No other
|
||
> operation deletes a file; prune deletes *only* files that no index references.**
|
||
> A bank op that deletes a file is still a bug — prune is not a bank op, it is the
|
||
> file-lifecycle op.
|
||
|
||
This asymmetry is deliberate and must be stated loudly: every *other* invariant
|
||
says "no operation deletes a file." Prune is the sole, explicit exception, and its
|
||
entire job is deletion — so it must be the *only* file-deleting authority in the
|
||
system, with the strongest guardrails.
|
||
|
||
## Mirror of `reconcile` — the pure pattern one level down
|
||
|
||
Prune reuses the shape Design View already shipped. `view_mode_model`'s
|
||
`ViewModeModel::reconcile(liveGuids)` reconciles *membership entries* against *live
|
||
tracks* and returns the residuals to drop. **Prune reconciles *files on disk*
|
||
against *referenced files*** (the union of every bank's index) and returns the
|
||
orphan set to delete. Same pure pattern, one level down (files instead of GUIDs).
|
||
|
||
The **decision is pure and unit-tested**: given the set of files present in the
|
||
bank folder and the set of files referenced by the book, compute the orphan set.
|
||
Only the two ends touch the shell — *enumerating* the bank folder and *deleting*
|
||
the orphans are filesystem I/O. Keep the "which files are orphans" core REAPER-free
|
||
and hard-tested (this is the safety-critical part); keep the I/O thin. Same
|
||
pure/shell split as `bank_model` / `view_mode_model` / `bank_book`.
|
||
|
||
## Settled decisions (spec-level)
|
||
|
||
- **Referenced-set is the union across ALL banks, pool included.** A file is an
|
||
orphan iff **no** bank in the book references it. Because copy lets one file be
|
||
referenced by several banks, prune must union references across the whole book
|
||
before deciding. This is the safety-critical computation — the **prune null
|
||
test** is *prune never deletes a file that any index references.*
|
||
- **Project-relative resolution, current folder.** Prune enumerates and deletes
|
||
within the project bank folder using the **same M4 project-relative path
|
||
resolution** the index uses, against the *resolved current* folder — never a
|
||
stale absolute path — so a Save-As relocation cannot cause it to mis-identify or
|
||
mis-target orphans.
|
||
- **Dry-run first, always.** Prune reports before it deletes: the orphan count,
|
||
reclaimed size, and (for a small set) the files. The dry-run — compute-and-report,
|
||
the pure core with no deletion — is the primary surface; actual deletion is the
|
||
confirmed second step. A prune that silently sweeps is unacceptable for an
|
||
irreversible file-delete.
|
||
- **Scope is the bank system's own leavings, not the folder at large.** Prune
|
||
reclaims files that *were* bank files and are now unreferenced — never a file a
|
||
user hand-dropped into the folder. Prune is a reclaimer of ReaSampler's own
|
||
orphans, not a general folder cleaner.
|
||
- **Orphan attribution is an owned-file manifest (fork R-D, SETTLED 2026-07-24).**
|
||
The book tracks the set of files it has created (an **owned-file manifest**);
|
||
prune reclaims `(owned ∩ on-disk) − referenced`. This is the honest encoding of
|
||
"reclaim only our own leavings" and rejects folder-sweep (which would delete
|
||
hand-dropped files). **The seam lands early:** because the manifest is cheap to
|
||
maintain from capture onward but a backfill cliff to reconstruct later, **capture
|
||
writes each file it creates into the owned-file manifest starting in Phase B**,
|
||
even though prune consumes it only in Phase R. R1/R2 consume the manifest; they do
|
||
not build it. The manifest is persisted in the `"reasampler"` ext-state; the exact
|
||
persistence shape (a sibling key vs. folded into the `banks` blob) is a small
|
||
build-time residual, but the manifest-now decision is firm.
|
||
|
||
## Precision / invariant implications
|
||
|
||
- **The single intentional exception to "no operation deletes files."** Stated
|
||
above; called out again here so the invariant table is honest: prune is
|
||
destructive-to-files *by design and by exclusive authority*.
|
||
- **Relative-paths-only / Save-As machinery reused** — prune resolves paths the
|
||
same way the index does (M4), so it inherits relative-path correctness and
|
||
Save-As survival; it introduces no new path handling.
|
||
- **Determinism / bit-identical / null-test (capture)** untouched — prune sits
|
||
below the capture path entirely.
|
||
- **Prune null test (new invariant):** a prune of a folder whose every file is
|
||
referenced by some bank deletes nothing; a prune deletes exactly the
|
||
`present − referenced` orphan set and nothing else. Ship as a tested property of
|
||
the pure core.
|
||
|
||
## Guardrails — the genuinely destructive act
|
||
|
||
- **Dry-run + confirm-with-manifest** (above): the user approves a *specific*
|
||
deletion (count + size + files), never an abstract "clean up."
|
||
- **Never a referenced file; never a non-bank file.** The union-across-all-banks
|
||
rule protects referenced files; the ownership-attribution rule (fork R-D)
|
||
protects hand-dropped files.
|
||
- **Safest platform deletion available (fork R-C, SETTLED 2026-07-24 — trash-
|
||
preferred, unlink fallback).** Route deletions to the platform recycle bin / trash
|
||
wherever a portable move-to-trash is available (recoverable outside the app); fall
|
||
back to unlink — behind the dry-run + confirm guardrail — only where the platform
|
||
affords no portable trash. "Delete where possible" means recoverable-trash-
|
||
preferred, never plain unlink-by-default. The move-to-trash surface is an explicit
|
||
per-platform **to-verify** (see REAPER/platform API surface). (R3 verified: Windows
|
||
`SHFileOperationW` + `FOF_ALLOWUNDO` confirmed against SDK 10.0.26100; macOS/Linux
|
||
unlink fallback — no portable SWELL trash surface.)
|
||
- **Manual, explicit trigger (fork R-E, SETTLED 2026-07-24 — manual action + panel
|
||
button).** Prune runs via a bindable manual action (dry-run-first, confirm-to-
|
||
delete) **and** a `bank_panel` button that fires that same action — never a silent
|
||
background sweep. The earlier optional "…and prune now at the delete-bank
|
||
confirmation" convenience was **not** selected and is out of scope; a periodic
|
||
background sweep remains rejected (silent irreversible file-deletion violates the
|
||
guardrails).
|
||
|
||
## Module architecture (preserve the pure/shell split)
|
||
|
||
Pure (no REAPER types, unit-tested — the mirror of `reconcile`):
|
||
- **Prune-reconcile core** — given `{ files present in the bank folder }`,
|
||
`{ files referenced by the book }`, and `{ files the book owns }` (the owned-file
|
||
manifest, R-D), compute the orphan set `(owned ∩ present) − referenced`.
|
||
REAPER-free, filesystem-free, unit-tested hard (the prune null test lives here).
|
||
The referenced-set is unioned across all banks by asking the `bank_book`.
|
||
|
||
REAPER-facing / filesystem-facing (thin):
|
||
- `persist` / session — supplies the referenced-set (union across the book) and the
|
||
owned-file manifest (R-D, written from capture onward in Phase B); resolves the
|
||
current project bank folder via the M4 project-relative machinery.
|
||
- A **prune shell** — enumerates the bank folder (filesystem I/O), feeds the
|
||
pure core, presents the dry-run manifest, and on confirmation deletes the orphan
|
||
set (via OS trash where portably available — fork R-C — else unlink). Filesystem
|
||
I/O only; the decision stays in the pure core.
|
||
- `actions` (entry) — "Prune bank folder" (dry-run-first, confirm-to-delete),
|
||
registered with the `command_id`/`gaccel`/`hookcommand` contract; **plus a
|
||
`bank_panel` button** (R-E) that fires the same action.
|
||
|
||
## REAPER / platform API surface (verify all signatures)
|
||
|
||
No new REAPER *audio* API. New surfaces to verify before use:
|
||
- **Filesystem enumeration + delete** — directory listing and file removal for the
|
||
project bank folder. **Verify** the portable approach against SWELL / the existing
|
||
file-handling in `persist` / `capture` (which already resolve and write files);
|
||
prefer reusing whatever path/file machinery M4 established.
|
||
- **Move-to-trash (fork R-C, settled trash-preferred)** — verify a portable
|
||
move-to-trash exists (SWELL, or per-platform: Win `IFileOperation`/
|
||
`SHFileOperation`, macOS `NSFileManager trashItemAtURL:`, Linux XDG trash spec).
|
||
This is a **must-verify per platform** before use, not an assumed capability;
|
||
where it is unavailable, fall back to unlink behind the dry-run/confirm guardrail.
|
||
(R3 verified: Windows routes to Recycle Bin via `SHFileOperationW` + `FOF_ALLOWUNDO`,
|
||
verified against SDK 10.0.26100. macOS / Linux: no portable SWELL trash surface
|
||
found — fall back to `unlink` behind the dry-run/confirm guardrail, as specified.)
|
||
- **Owned-file manifest persistence (fork R-D, settled)** — a new tracked set in
|
||
the `"reasampler"` ext-state (a sibling key or folded into the `banks` blob —
|
||
build-time residual); shared M4 blob machinery, new data only. **Written from
|
||
capture onward in Phase B** (the seam lands early), consumed by prune in Phase R.
|
||
- **Actions** — the `command_id`/`gaccel`/`hookcommand` contract from `main.cpp`
|
||
(unchanged), a new command-id string under the sampler family prefix.
|
||
|
||
## Non-goals / guardrails
|
||
|
||
- **Prune is the ONLY file-deletion authority.** No bank op, no capture op, no
|
||
Design View op deletes a file. If any path other than prune deletes a bank file,
|
||
reject it in review.
|
||
- **No general folder cleaning.** Prune reclaims the bank system's own unreferenced
|
||
leavings, not arbitrary files a user placed in the folder (fork R-D governs the
|
||
attribution).
|
||
- **No silent deletion.** Dry-run + explicit confirm always; no background sweep.
|
||
- **No file deleted while any index references it.** The referenced-set union
|
||
across all banks is the safety-critical invariant — enforce and test it in the
|
||
pure core, not just the UI.
|
||
- **Additive only.** Prune reads the book and the folder; it does not modify
|
||
`BankIndex`, `bank_book`, the capture roadmap, or Design View semantics.
|
||
|
||
## Settled forks (Daniel, 2026-07-24)
|
||
|
||
- **Fork R-C — deletion mechanism.** Settled: **trash-preferred, unlink fallback.**
|
||
Route to the OS trash where a portable move-to-trash is available (recoverable),
|
||
else unlink behind the dry-run/confirm guardrail. Per-platform trash surface is a
|
||
must-verify. Folded into Settled decisions + Guardrails + API surface above.
|
||
Product notes §Fork R-C.
|
||
- **Fork R-D — orphan attribution.** Settled: **owned-file manifest**, `(owned ∩
|
||
present) − referenced`; folder-sweep rejected as unsafe. The **seam lands early** —
|
||
capture writes each created file to the manifest starting in Phase B, prune
|
||
consumes it in Phase R. Persistence shape (sibling key vs. `banks` blob) is a
|
||
build-time residual. Folded into Settled decisions + Module architecture + API
|
||
surface above, and added as an up-front Phase B / capture plan point. Product
|
||
notes §Fork R-D.
|
||
- **Fork R-E — trigger.** Settled: **manual action + `bank_panel` button**, dry-run-
|
||
first, confirm-to-delete; no background sweep. The delete-time "…and prune now"
|
||
convenience was not selected (out of scope). Folded into Guardrails + Module
|
||
architecture above and the R3 plan points. Product notes §Fork R-E.
|
||
|
||
**Build-time residual (not a fork):** the owned-file manifest's exact persistence
|
||
shape (sibling `"reasampler"` ext-state key vs. folded into the `banks` blob).
|
||
|
||
---
|
||
|
||
# MIDI-playback instrument — additive phase spec (Phase S — Sampler)
|
||
|
||
> **New pillar, its own lettered phase, and — uniquely — its own build artifact.**
|
||
> Every prior phase (M / D / B / R / V) ships inside the one `reaper_reasampler`
|
||
> extension binary. Phase S does **not**: a REAPER extension *cannot* be a
|
||
> MIDI-triggered instrument (it is not a node in any track's signal chain), so the
|
||
> instrument is a **second, separate binary** — a native **VST3** plugin the user
|
||
> instantiates on an instrument track — that reads ReaSampler's banks and plays them
|
||
> MIDI-triggered. Namespaced **`S` (Sampler)** rather than "D" (which would collide
|
||
> with Design View). The `M`/`D`/`B`/`R`/`V` extension pillars are untouched. Product
|
||
> framing, the plugin-format reasoning, the bare-VST3-vs-JUCE assessment, and the
|
||
> settled decision record: `docs/product/midi-playback.md`. Same standing discipline:
|
||
> **verify every Steinberg VST3 SDK and REAPER/SWELL API name/signature against the
|
||
> vendored headers before use.**
|
||
|
||
## What it is
|
||
|
||
A native **VST3 sampler instrument** — a separate product/artifact from the
|
||
extension — that maps ReaSampler's captured bank samples across a MIDI keyboard and
|
||
plays them back with a real voice engine (polyphony, velocity, envelopes). The
|
||
extension stays the sole owner of **capture + organization**; the instrument is the
|
||
**playback surface**. The two are *tightly integrated but distinct acts*: the
|
||
extension captures and organizes; the instrument plays. Neither crosses into the
|
||
other's role — the instrument never captures, the extension never becomes an
|
||
instrument.
|
||
|
||
**Why a VST3 and not the extension (load-bearing, settled — see D1/D5/D6 below).** An
|
||
instrument track's "read live MIDI, emit audio per-voice, in REAPER's
|
||
routing/record/render path" contract belongs to VST/VST3/CLAP/JSFX plugins, hosted
|
||
through an entirely different mechanism than the extension API. The extension SDK's
|
||
audio-adjacent surfaces (`Audio_RegHardwareHook`, `kbd_OnMidiEvent`, `PlayPreview`,
|
||
`pcmsrc` subclassing) are each the wrong tool for a live-MIDI instrument — the full
|
||
reasoning is in `docs/product/midi-playback.md` §1. The instrument is therefore a
|
||
standard VST3 plugin; this is not an engineering-around-able limitation, it is what
|
||
the plugin *format* is.
|
||
|
||
## The three locked decisions this spec assumes
|
||
|
||
Settled by Daniel (2026-07-26); everything below assumes them. Reasoning preserved in
|
||
`docs/product/midi-playback.md` §4.
|
||
|
||
- **D1 — native VST3.** Not JSFX. Full sampler sophistication, clean integration, and
|
||
access to the REAPER VST-host bridge. JSFX retired (cross-platform-for-free is
|
||
worthless under D5, and JSFX gets no bridge).
|
||
- **D5 — Windows-only, VST3-only, REAPER-only.** No cross-platform DSP/build/signing
|
||
matrix, no multi-format wrapper, no standalone-in-other-hosts concern. REAPER-coupling
|
||
via the bridge is intended. This is the single biggest simplifier — it deletes most of
|
||
what makes VST3 painful.
|
||
- **D6 — two products, tightly integrated.** A separate artifact, but **not** a
|
||
divorced file-only companion: via the VST-host bridge it reads the live
|
||
`"reasampler"` project ext-state and is project-aware.
|
||
|
||
## The VST-host bridge (the integration mechanism, stated once)
|
||
|
||
A VST3 *hosted inside REAPER* can call back into REAPER's own API by resolving
|
||
function pointers by name over the host callback
|
||
(`hostcb(&effect, 0xdeadbeef, 0xdeadf00d, 0, "FunctionName", 0.0)` — the same
|
||
string-keyed API table the extension uses; verified in `video_processor.h` and the
|
||
`reaper_plugin_functions.h` `GetProjExtState`/`SetProjExtState`/`EnumProjExtState`
|
||
entries). The plugin can also fetch its **host context** — the track/take/project it
|
||
was instantiated in (opcode `0xdeadf00e`). **Consequence:** the instrument reads the
|
||
*same* live `"reasampler"` ext-state that `persist` writes, follows the active
|
||
project, and needs no "point me at the bank folder" wiring — it asks REAPER which
|
||
project it is in. This capability exists *because* the plugin is hosted in REAPER; it
|
||
is the technical affordance D6 leaned on. **Must-verify before build:** confirm the
|
||
bridge opcodes and the by-name resolution against the vendored
|
||
`vendor/reaper-sdk/sdk/` headers (`reaper_plugin.h`, `video_processor.h`,
|
||
`reaper_plugin_functions.h`) — the framing doc's opcode citations are verified from
|
||
those headers but the exact call marshalling should be confirmed at the spike.
|
||
|
||
## The two seams (audio via files, mapping via live state)
|
||
|
||
- **File seam (audio, permanent).** The sample **audio** is the on-disk 32-bit-float
|
||
WAVs — project-relative, travelling with the `.rpp` via the M4 machinery. The
|
||
instrument resolves those paths **the same way `persist` does** (a shared convention,
|
||
not a re-implementation). There is no live PCM stream across the bridge, by design.
|
||
- **Live-state seam (the mapping, via the bridge).** For everything that is *not* raw
|
||
audio — the bank index, the mapping, which project is active — the instrument reads
|
||
the live `"reasampler"` ext-state via the bridge. It sees what `persist` last wrote
|
||
and follows the active project.
|
||
|
||
## The seam fields — what becomes a bank intrinsic (D-B, settled 2026-07-26)
|
||
|
||
**The split model (option iii) is the settled answer.** It mirrors the
|
||
capture/placement separation:
|
||
|
||
- **Bank intrinsics (facts about the captured file) live on `Sample`.** **Root note**
|
||
(the MIDI note the sample was recorded at, so it can be repitched across the
|
||
keyboard — distinct from the existing optional *musical key* field) and **loop
|
||
points** (sustain-loop start/end for held notes; sample-accurate, zero-crossing-aware)
|
||
are *facts about the file*, analogous to sample rate, length, and peaks. They are added
|
||
to `Sample` as an **additive field extension** — the same shape as how `provenance`
|
||
was added in M1: new optional fields with JSON round-trip, populated at/after capture,
|
||
defaulting cleanly for pre-existing samples. This keeps the bank a clean,
|
||
tool-agnostic library (WAVs + facts, readable by anything).
|
||
- **The performance map (a creative arrangement) lives in the instrument.** **Key
|
||
zones** (low/high note per sample), **velocity layers**, **round-robin groups**,
|
||
**amplitude envelopes** (ADSR), and per-sample tuning/gain trim are a *performance
|
||
choice*, not a fact about a file — they belong to the instrument, not the bank. Under
|
||
the live-state seam the instrument may still *read* performance-map data out of shared
|
||
`"reasampler"` state, so "who owns which field" is a data-ownership decision, not a
|
||
transport one.
|
||
|
||
**Why the intrinsic fields are added early (D-B, the backfill-cliff reasoning).** The
|
||
`Sample` field addition is scheduled as an **early Phase S point** even though the
|
||
instrument that consumes them lands later. Rationale (the *design-the-seam-even-if-you-
|
||
defer-the-feature* instinct, same as Fork R-D's owned-file manifest): if the fields are
|
||
added only when the instrument needs them, every sample captured before then lacks a
|
||
root note / loop points and must be backfilled by hand. Adding the fields now — so
|
||
capture starts populating them (or at least defaulting them cleanly) — costs almost
|
||
nothing and closes the cliff. The field addition touches the **extension** codebase
|
||
(`bank_model` + capture + persist), is independently shippable, and lands before the
|
||
instrument build leans on it.
|
||
|
||
## Scope tiers (D-C, settled 2026-07-26 — Tier 0–1 committed, Tier 2 held, Tier 3 optional-forever)
|
||
|
||
Tiers are minimal → sophisticated; **Tier 0 delivers the core promise** and each tier
|
||
above is optional depth, not a prerequisite for the one below.
|
||
|
||
- **Tier 0 — "the bank plays" (committed).** One sample mapped chromatically across
|
||
the keyboard from its root note; basic polyphony; a simple amp envelope; velocity →
|
||
volume. The honest MVP: point a bank sample at a MIDI track and play it repitched.
|
||
On the native path this is the `SingleComponentEffect` skeleton plus a single-voice
|
||
core, editor deferrable behind a parameters-only default view.
|
||
- **Tier 1 — "a keymap" (committed).** Multiple samples zoned across the keyboard (key
|
||
ranges), each with its own root note — a captured *kit* or a *multisampled instrument*
|
||
plays correctly. This is where the root-note + key-range seam fields earn their place.
|
||
One sample per key-region.
|
||
- **Tier 2 — "expressive" (HELD — noted, not specified).** Velocity layers, round-robin
|
||
(the anti-machine-gun feature), full ADSR, per-sample tuning/gain trim, sustain loops.
|
||
Where it becomes a tool people reach for. **Explicitly a follow-on** — its points are
|
||
not drawn up in this spec; it is recorded as the next depth increment once Tier 0–1
|
||
proves the instrument belongs in ReaSampler's world.
|
||
- **Tier 3 — "instrument polish" (optional-forever).** Filters, filter/pitch envelopes,
|
||
LFOs, per-voice pan, choke groups, a modest FX slot. A direction to leave room for,
|
||
never a commitment. Do **not** let a Tier-3 feature list inflate the build-shape
|
||
decisions.
|
||
|
||
## The build shape (D-A, settled 2026-07-26 — bare Steinberg VST3 SDK + LICE editor)
|
||
|
||
**Settled: bare Steinberg VST3 SDK, no JUCE, with the editor drawn in the same
|
||
LICE/SWELL stack `bank_panel` already uses.** Reasoning (full assessment in
|
||
`docs/product/midi-playback.md` §1a and §4 D-A):
|
||
|
||
- **The audio-processing scaffolding is bounded.** Using `SingleComponentEffect` (the
|
||
SDK's combined processor+controller base — sanctioned for a non-distributable,
|
||
REAPER-only plugin under D5/D6) plus the SDK's factory macros, a silent-but-loading
|
||
VST3 instrument skeleton is order-of-magnitude a few-hundred lines of
|
||
adapt-from-example ceremony, written once. The AGain / Note Expression Synth SDK
|
||
examples are the copy-source. Not a tar pit.
|
||
- **D5 deletes JUCE's biggest justification.** JUCE exists largely for multi-format /
|
||
cross-platform, both of which D5 removed. Its one genuine remaining pull is the editor
|
||
UI — and ReaSampler is the atypical case where even that is weak, because it already
|
||
has a working, docked, custom-drawn LICE UI (`bank_panel`) and a house style. Drawing
|
||
the editor in a VST3 `IPlugView` that hosts a LICE surface reuses that muscle, keeps
|
||
the look house-consistent, and avoids JUCE's AGPL-or-pay license posture (the Steinberg
|
||
SDK is permissive, no revenue gate).
|
||
- **The one real edge — the `IPlugView`↔LICE bridge** (window lifecycle, sizing, event
|
||
routing from the host into the draw/hit-test loop) — is *the same class of work*
|
||
ReaSampler already did to dock `bank_panel`, not a new competence, but it is less
|
||
trodden than dropping in a JUCE editor. It is therefore the phase's **opening spike**
|
||
(below), which also converts §1a's experienced-estimates (Windows module-export
|
||
symbol names, factory-macro spellings, exact bridge marshalling) into verified fact
|
||
before the engine build leans on them. VSTGUI (the SDK's bundled toolkit) is the noted
|
||
fallback rung *only if* the LICE bridge proves gnarlier than the panel work suggests;
|
||
JUCE is the last resort behind that.
|
||
|
||
## The pure core (D3 — the load-bearing split, transplanted)
|
||
|
||
**The sampler's voice engine, envelope math, key/velocity mapping, repitch/interpolation,
|
||
and keymap resolution are a pure, REAPER-free, DAW-free, unit-tested module** — the
|
||
mirror of `bank_model` / `peaks` / `view_mode_model` / `bank_book`, tested in the CTest
|
||
harness outside any host. This is the heart of the phase; **test it hard.** The VST3
|
||
wrapper — the `SingleComponentEffect` subclass, bus setup, the `process` call
|
||
marshalling MIDI→core and core→audio-buffer, the `IPlugView` LICE editor, and the bridge
|
||
calls that read `"reasampler"` ext-state — is the **thin shell**, the only part that
|
||
touches VST3 or REAPER at all. Critically, this split is **invariant under the build-shape
|
||
choice**: whether the shell is bare-SDK or (hypothetically) JUCE, the pure core is
|
||
identical, REAPER-free, and tested the same way. The format choice is a shell choice; the
|
||
core is invariant.
|
||
|
||
## Module architecture (preserve the pure/shell split — in the new artifact)
|
||
|
||
Pure (no REAPER types, no VST3 types, unit-tested — the mirror of `bank_model`):
|
||
- **Sampler core** — voice allocation/polyphony, amplitude envelope (ADSR), key→sample
|
||
and velocity→sample mapping (the keymap), repitch/interpolation from root note, and
|
||
keymap resolution. REAPER-free *and* VST3-free, unit-tested in CTest against known
|
||
signals (mirror of how `peaks` asserts an envelope). This is D3's pure core and the
|
||
heart of the phase.
|
||
|
||
Shell (VST3-facing / REAPER-facing, thin):
|
||
- **VST3 wrapper** — `SingleComponentEffect` subclass: `initialize` (declare an event
|
||
input bus + an audio output bus, no audio input), `setupProcessing`, `setActive`,
|
||
`setState`/`getState`, and the hot-path `process` that reads MIDI off the event bus,
|
||
drives the pure core, and writes the core's per-voice audio to the output bus. Plus the
|
||
module factory (`GetPluginFactory` + Windows `InitDll`/`ExitDll` — verify exact export
|
||
names at the spike).
|
||
- **`IPlugView` LICE editor** — hosts a LICE-drawn surface in the VST3 view seat
|
||
(window creation/sizing, host→draw/hit-test event routing). Reuses the `bank_panel`
|
||
LICE/SWELL competence and house style.
|
||
- **Bridge/state reader** — resolves `GetProjExtState`/`EnumProjExtState` by name over
|
||
the host callback, fetches the host project context, reads the live `"reasampler"`
|
||
ext-state (bank index + intrinsic fields + performance map), and resolves WAV audio
|
||
paths the same project-relative way `persist` does.
|
||
|
||
## Precision / invariant implications
|
||
|
||
- **The bank is one source; the instrument is another view of it (never a fork).** The
|
||
instrument is a pure *consumer* of the bank — it does not copy samples, does not own a
|
||
private sample store, and does not mutate the bank. The bank stays the single
|
||
authoritative artifact (the one-source-multiple-views instinct). Any instrument path
|
||
that writes back into the bank or keeps its own sample copies is a bug.
|
||
- **Capture/placement/playback stay distinct acts.** The instrument reads and plays; it
|
||
never captures and never inserts into the arrange. The capture load-bearing principle
|
||
is untouched — Phase S adds a *third* distinct act (playback) without weakening the
|
||
capture↔placement separation.
|
||
- **`Sample` field addition is additive and lossless.** Root note + loop points are new
|
||
optional fields with JSON round-trip, defaulting cleanly for samples captured before
|
||
the addition — the same additive, backward-compatible shape as `provenance` (M1). No
|
||
existing `Sample` field changes; no `BankIndex` behavior changes.
|
||
- **Relative-paths-only survives.** The instrument resolves audio via the M4
|
||
project-relative machinery; it introduces no absolute paths.
|
||
|
||
## Embedded TCP/MCP UI (D-D, settled 2026-07-26 — SCHEDULED as a later Phase S point)
|
||
|
||
A REAPER-hosted VST3 can draw its own UI **inline in the track/mixer control panel**
|
||
via `reaper_plugin_fx_embed.h` (the plugin implements `IReaperUIEmbedInterface`; the
|
||
same Cockos surface REAPER's own embedded FX use). A ReaSampler instrument can render a
|
||
compact keymap/level strip inline in the TCP/MCP, not only in its own window. Because
|
||
this uses the **same LICE-class drawing as the D-A editor path**, it composes naturally
|
||
with the bare-SDK-plus-LICE build — the groundwork is the groundwork.
|
||
|
||
**Settled: scheduled, not deferred.** This is a real, in-phase later point — it lands
|
||
**after** the main `IPlugView` editor exists (it composes with that LICE path), not a
|
||
someday-note. It is polish, not a Tier-0 need, so it sequences last in the phase; but it
|
||
is on the roadmap. **Must-verify before build:** the `IReaperUIEmbedInterface` contract
|
||
and embed message/lifecycle against `vendor/reaper-sdk/sdk/reaper_plugin_fx_embed.h`.
|
||
|
||
## REAPER / Steinberg API surface (verify all signatures)
|
||
|
||
- **VST3 SDK (a new vendored dependency — vendor it at the spike).** `FUnknown` and the
|
||
`IComponent` / `IAudioProcessor` / `IEditController` interface family; the
|
||
`SingleComponentEffect` / `EditControllerEx1` / `AudioEffect` base classes; the class
|
||
factory (`GetPluginFactory` + factory macros); `IPlugView` for the editor;
|
||
`ProcessData` / `ProcessSetup` for the hot path. **Verify** interface members, the
|
||
base-class overrides, factory-macro spellings, and the Windows module-export symbol
|
||
names (`InitDll`/`ExitDll`/`GetPluginFactory`) against the vendored SDK at the spike —
|
||
the framing doc flags several of these as experienced estimates.
|
||
- **REAPER VST-host bridge.** `hostcb` opcode `0xdeadf00d` (resolve API function by
|
||
name) and `0xdeadf00e` (host context); the by-name resolution of
|
||
`GetProjExtState`/`SetProjExtState`/`EnumProjExtState`. **Verify** against
|
||
`vendor/reaper-sdk/sdk/reaper_plugin.h` + `video_processor.h` +
|
||
`reaper_plugin_functions.h`.
|
||
- **Embedded UI (D-D, later point).** `IReaperUIEmbedInterface` and the embed
|
||
message/lifecycle contract — verify against
|
||
`vendor/reaper-sdk/sdk/reaper_plugin_fx_embed.h` before use.
|
||
- **LICE/SWELL editor.** Reuses the `bank_panel` LICE/SWELL drawing surface; verify the
|
||
`IPlugView`↔LICE window/bitmap bridge at the spike (window creation, sizing, event
|
||
routing) — the least-trodden edge of the phase.
|
||
|
||
## Non-goals / guardrails
|
||
|
||
- **The instrument never captures and never inserts into the arrange.** Playback is a
|
||
read-only act over the bank. Any instrument path that captures, places a timeline item,
|
||
or writes back into the bank is a bug — reject in review.
|
||
- **The instrument keeps no private copy of the samples.** It consumes the one
|
||
authoritative bank; per-instance sample stores are a non-goal (they refork the source
|
||
the one-source-multiple-views instinct keeps single).
|
||
- **No cross-platform / multi-format.** Windows-only, VST3-only, REAPER-only (D5). Do
|
||
not add an AU/AAX/VST2/CLAP wrapper, a mac/Linux build, or a standalone host target.
|
||
- **The pure core stays REAPER-free *and* VST3-free.** The voice engine / envelope /
|
||
keymap / repitch module takes no VST3 or REAPER type at its boundary — the shell
|
||
marshals. Any VST3 or REAPER type leaking into the core is a bug (the D3 split).
|
||
- **Additive to the extension.** The `Sample` intrinsic-field addition is additive
|
||
(new optional fields; no existing field or `BankIndex` behavior changes); everything
|
||
else in Phase S lives in the *second* artifact and does not alter the extension's
|
||
M/D/B/R/V pillars.
|
||
- **Do not spec Tier 2/3.** Tier 2 is held (noted, not specified); Tier 3 is
|
||
optional-forever. Do not let their feature lists drive Tier 0–1's build shape.
|
||
- **Verify Steinberg SDK, bridge, embed, and LICE-view surfaces** against the vendored
|
||
headers before use — several §1a claims are experienced estimates until the spike
|
||
confirms them.
|
||
|
||
---
|
||
|
||
# Look-and-feel — visual design language (Phase L)
|
||
|
||
> **New pillar, own lettered phase, taken up by a parallel team.** Phase L is the
|
||
> whole-system look-and-feel effort — a shared LICE drawing kit and the surfaces that
|
||
> adopt it — that replaces the flat "temple os" drawing (opaque `LICE_FillRect` blocks +
|
||
> raw GDI `DrawTextA`) with a modern, sleek 2026 dark synth look across ReaSampler
|
||
> (the extension's docked bank panel) and ReaSampler 9000 (the Phase S VST editor + embed
|
||
> strip). Namespaced **`L` (Look-and-feel)**, orthogonal to and ungated by the
|
||
> M/D/B/R/V/S pillars. Product framing, the settled decision record (DS-1/DS-2/DS-3 all
|
||
> SETTLED 2026-07-26), palette, the three visual directions, and the toolkit assessment:
|
||
> `docs/product/visual-design-language.md`. Same standing discipline: **verify every LICE/
|
||
> WDL/SWELL API name/signature against `vendor/WDL` before use.** When a point lands,
|
||
> doc-keeper moves it to `COMPLETED.md`.
|
||
|
||
## What it is
|
||
|
||
A **shared LICE-based drawing kit** (palette + type scale + component-draw layer) and the
|
||
surfaces that consume it. The kit is the **one source of drawing** for the whole system —
|
||
a button, row, slider, or waveform looks identical in the bank panel, the embed strip, and
|
||
the VST editor because it is the same kit function (the one-source-multiple-views instinct,
|
||
applied to drawing). The current "temple os" look is the system drawing at the **floor** of
|
||
LICE (flat fills, GDI text, no gradients/AA/rounded/hover); the kit lifts every surface to
|
||
LICE's actual ceiling — which REAPER's own themed UI and SWS prove is a modern dark UI.
|
||
|
||
## Settled decisions (Daniel, 2026-07-26 — reasoning in `docs/product/visual-design-language.md` §6)
|
||
|
||
- **DS-1 — toolkit: LICE + WDL free game, no external frameworks.** Draw with **LICE**
|
||
directly (gradients via `LICE_GradRect`, AA rounded via `LICE_RoundRect`/`LICE_Line`,
|
||
cached AA text via `LICE_CachedFont`). **Reuse any useful WDL/vwnd piece** — skin/image
|
||
helpers, draw idioms, a specific control (e.g. `virtwnd-listbox` for a long scroll list)
|
||
— where it beats re-deriving; "don't reinvent the wheel." **Reject external frameworks**
|
||
(iPlug2 / JUCE / VSTGUI — they re-open the settled bare-SDK+LICE build shape to solve a
|
||
look problem that is not a toolkit-ceiling problem). **Caution, not a ban:** keep
|
||
hit-test **geometry** in pure CTest-covered modules — do not import vwnd's retained-mode
|
||
object model wholesale (its controls own their hit-test internally, which would move
|
||
geometry into untestable shell code and undercut the pure/shell split).
|
||
- **DS-2 — visual direction: Direction B ("Neon Console") + Direction C's spectral
|
||
keyboard strip. SETTLED 2026-07-26, REVISED 2026-07-26 (Daniel) — three-accent pastel
|
||
accents AND REAPER-grey neutrals (palette-only).** *Neutral surfaces (revised):* the
|
||
neutral ladder moved **from near-black up into REAPER's mid-grey theme family** so the
|
||
dock reads as *part of REAPER, not a black slab* — `bg/base` ≈ `#2b2b2b` (REAPER chrome),
|
||
`bg/panel` ≈ `#333333`, `bg/cell` ≈ `#3a3a3a` (REAPER track bg), `line/hairline` ≈
|
||
`#4a4a4a`, `text/primary` ≈ `#dcdcdc`, `text/dim` ≈ `~#a0a0a0`+. Elevation-ladder
|
||
discipline unchanged (base < panel < cell by a few %, micro-gradient + inner
|
||
highlight/shadow carry elevation, not hard borders). *Accent layer (revised):* changed
|
||
from **one electric cyan** to **three pastel accents**: `accent/primary` = **pastel lime
|
||
green** (`~176,224,152`, the
|
||
live/active/selected signal), `accent/secondary` = **pastel teal** (`~132,214,208`,
|
||
categorical role A), `accent/tertiary` = **pastel purple** (`~194,170,232`, categorical
|
||
role B); `accent/hot` is a lighter pastel-lime tint (`~200,236,178`). These are *starting*
|
||
values — the implementer locks final hex against the theme module's WCAG tests, staying
|
||
**within the pastel intent** ("soft-side" floor: the most pastel value that still clears,
|
||
not re-saturated toward neon). The **spectral (hue-mapped) keyboard strip** — signature
|
||
surface, glow-on-active as a static drawn state — is now a **pastel** sweep anchored on the
|
||
three accents (pastel-lime low → pastel-teal mid → pastel-purple high). **A stylish/bundled
|
||
font upgrade was considered and DECLINED (Daniel, 2026-07-26)** — no font bundling or
|
||
redistribution; the kit keeps its **current** cached-font face and no new typeface is
|
||
specified (§3.1). The kit palette stays **abstract** (role→color, one constants block —
|
||
now three accent roles), so the direction is a single-file change. *Tight pairs flagged for
|
||
the implementer (re-verify against the GREY ladder, not near-black):* **`text/dim` on
|
||
grey** (mid-grey-on-mid-grey — lands ~4:1 at start, must be lifted toward `~#a8a8a8`+ to
|
||
clear AA 4.5:1 body on `#333`/`#3a3a3a`, the classic floor failure); **the three pastels
|
||
as state indicators / active fills on `bg/cell`** (the pastel cushion shrank from ~15:1 on
|
||
near-black to ~6:1–7:1 on grey — still clears 3:1 but must be re-checked; if any drops
|
||
below floor, nudge that hue slightly deeper within the pastel intent); body text **on a
|
||
pastel fill** (light-grey on a light pastel can drop below AA 4.5:1 — use dark labels on
|
||
pastel fills or darken the fill); and **secondary vs. tertiary** distinguishability (both
|
||
cool/desaturated — verify they read as distinct categories). Full reasoning:
|
||
`docs/product/visual-design-language.md` §2.1 + §4 + §6.
|
||
- **DS-3 — dock-panel scope: a thorough layout redesign, not a light re-skin.** L2 lays out
|
||
the *full* button/affordance inventory — including M11's action-trigger buttons +
|
||
keybinding-help labels — intuitively, uncluttered, and useful, then applies the kit.
|
||
Sequenced after M11 merges so it designs against the actual landed button set.
|
||
|
||
## Palette + the "punch" rule (Daniel's standing taste)
|
||
|
||
Dark, modern, **visual punch over conservative contrast — now pastel, on REAPER-grey.** The
|
||
palette is defined by **role**, not hardcoded hue: `bg/base`, `bg/panel`, `bg/cell`,
|
||
`line/hairline`, `text/primary`, `text/dim`, `accent/primary`, `accent/secondary`,
|
||
`accent/tertiary`, `accent/hot`, `warn`. **Neutral surfaces (DS-2 revised, 2026-07-26):**
|
||
the neutral ladder sits in **REAPER's mid-grey theme family**, not near-black, so the dock
|
||
reads as *part of REAPER* — `bg/base` ≈ `#2b2b2b`, `bg/panel` ≈ `#333333`, `bg/cell` ≈
|
||
`#3a3a3a`, `line/hairline` ≈ `#4a4a4a`, `text/primary` ≈ `#dcdcdc`, `text/dim` ≈ `~#a0a0a0`+.
|
||
A modern dark UI is built from **elevation layers**, not borders — surfaces gain a
|
||
**micro-gradient** (`LICE_GradRect`, a few percent lighter at the top) + a 1px inner
|
||
top-highlight / bottom-shadow (the vwnd trick) instead of flat fills; the grey ladder keeps
|
||
this discipline (base < panel < cell by a few %, subtle steps like REAPER, not hard
|
||
outlines). The three accents carry categorical meaning: **primary (pastel lime) =
|
||
live/active/selected**, **secondary (pastel teal) + tertiary (pastel purple) = supporting
|
||
categorical distinctions** (kinds, not intensity — primary is always "what's live now").
|
||
**WCAG-floor discipline (pastel + grey re-read):** moving neutrals *up* into grey makes two
|
||
pairs *harder* — (1) **`text/dim` on `bg/panel`/`bg/cell`** is the classic
|
||
mid-grey-on-mid-grey floor failure (must be lifted light enough to clear AA 4.5:1 body on
|
||
the greyest surface it draws on), and (2) **the three pastels as state indicators on grey**
|
||
have a shrunken contrast cushion (~6:1–7:1 vs. ~15:1 on near-black — still clear 3:1 but
|
||
re-verify; nudge a hue slightly deeper within the pastel intent if it drops below floor).
|
||
Text-on-a-pastel-fill (AA 4.5:1) remains a tight pair. Take the *most pastel* value that
|
||
still clears the floor **on grey** — approached from the **soft side**, never re-saturated
|
||
toward neon "to be safe," except where the grey floor forces a small deepening. The `warn`
|
||
role (red/amber) is reserved *only* for byte-deleting or clip states (prune, delete).
|
||
Because the palette is role-based in one constants block, the settled-and-revised
|
||
B+pastel+grey+spectral direction (DS-2) is one file.
|
||
|
||
## "Speed is the selling point" — a design constraint, not a tagline
|
||
|
||
The UI must **feel instant, and no decoration may cost that.** Sub-frame hover/press/drag
|
||
feedback repainted immediately on the input message (instant acknowledgment *is* the
|
||
perception of speed); zero-jank via the preserved double-buffer discipline (draw to
|
||
`LICE_SysBitmap`, single `BitBlt`); region-scoped `InvalidateRect` during a drag
|
||
(build-time residual). **No decorative animation** — no tweens/fades/pulses; the only
|
||
permitted motion is a level/meter readout following the audio directly (as the embed strip
|
||
already does). Direction C's glow/bloom is a **static drawn state, never a pulse.** The
|
||
"fast" feeling is typography + hover + no-jank, not motion.
|
||
|
||
## Kit architecture (the pure/shell split)
|
||
|
||
Pure (no LICE, no REAPER types, unit-tested — the mirror of `mode_switch`/`bank_grid`):
|
||
- **`theme`/palette module** — role→color mapping, direction-selectable via one constants
|
||
block (the B + REAPER-grey-neutral + three-accent-pastel + pastel-spectral values). Pure;
|
||
unit-tested that each text-on-surface pair clears its WCAG floor (the "punch" rule made
|
||
testable). *DS-2 revision (2026-07-26):* (a) the neutral ladder moves from near-black up
|
||
into REAPER's mid-grey family (`bg/base` `#2b2b2b` / `bg/panel` `#333333` / `bg/cell`
|
||
`#3a3a3a` / `line/hairline` `#4a4a4a` / `text/primary` `#dcdcdc` / `text/dim` `~#a0a0a0`+),
|
||
and (b) the accent role expands from one to three (`accent/primary` lime, `accent/secondary`
|
||
teal, `accent/tertiary` purple) with the spectral ramp a pastel sweep anchored on those
|
||
three — all still confined to the one constants block + the shell's font lifecycle. **The
|
||
WCAG test must be re-run against the GREY ladder** — it gains the newly-tight grey pairs:
|
||
`text/dim`-on-grey (AA 4.5:1) and each pastel-as-state-indicator on `bg/cell` (3:1), plus
|
||
the existing text-on-pastel-fill pairs. (No font change: a bundled-font upgrade was
|
||
declined — the cached-font set keeps the kit's current face.)
|
||
- **Component geometry/hit-test helpers** — button rect, slider track/handle geometry,
|
||
list-row rect + hover hit-test, and any new layout module L2 needs (an action-bar layout
|
||
module). No LICE, no host types; CTest-covered. Existing pure modules
|
||
(`bank_grid`/`tab_strip`/`mode_switch`) stay the source of truth for what they own.
|
||
|
||
Shell (LICE-facing, DAW-verified — thin draw layer):
|
||
- **Draw kit** — `fillSurface` (micro-gradient + inner highlight/shadow),
|
||
`drawButton`/`drawSlider`/`drawListRow`/`drawWaveform`/segmented-switch/tab draw, and a
|
||
shared `text()` over a cached `LICE_CachedFont` set (title/label/value-mono/micro). Owns
|
||
the cached-font lifecycle. Honors the interaction state model (rest/hover/active/pressed/
|
||
dragging/focus/disabled). **DS-1: WDL/vwnd reuse is assessed here at build time** — reuse
|
||
a vwnd piece where genuinely cheaper, else draw on LICE; hit-test geometry stays pure
|
||
regardless.
|
||
|
||
## L2 dock-panel layout contract (the M11-aware inventory)
|
||
|
||
DS-3 makes L2 a **layout design**, not a skin pass, because M11 adds a real button
|
||
inventory. L2 must place **every** affordance below without crowding the grid (the
|
||
centerpiece), grouping by *task*:
|
||
|
||
**Existing (landed / specced):**
|
||
- Bank **grid** — thumbnails, multi-select, keyboard nav, audition, focus ring.
|
||
- Design View **segmented mode switch** (`[ Arrange | Design ]`) + per-mode membership count.
|
||
- Multi-bank: **named-banks tab strip** (LICE-drawn, overflow/scroll), **active-bank
|
||
indicator**, **pool/banks full-height toggles**, create/rename/delete/activate-bank
|
||
affordances, per-selection **move / copy / remove** sample menu.
|
||
- **Prune** button (R-E) — the byte-deleting action; `warn`-colored, set apart.
|
||
|
||
**M11 adds (dev PLAN.md §M11 — merging to dev now):**
|
||
- **Action-trigger buttons** — clickable buttons firing the capture + provenance action
|
||
family directly (capture item / capture track scopes, re-capture from source,
|
||
resample-and-mute-source, batch capture, conform-on-insert, insert-at-cursor, drag-out,
|
||
null-test verify). A cluster.
|
||
- **Keybinding-help labels** — each capture/provenance action surfaces its current key
|
||
binding (e.g. "Capture Item → F5") or an "unbound"/"—" marker.
|
||
|
||
**Layout mandate:** group by task (capture / organize / reclaim / view), not by phase; a
|
||
compact action bar/toolbar for the frequent capture actions (icon+label, keybinding as a
|
||
`micro` sub-label), an overflow/menu for the rare ones, header space for the mode switch +
|
||
active-bank indicator, the bank tab strip + move/copy/remove organize cluster together,
|
||
prune set apart and `warn`-marked. Density is a design decision — 8px grid, elevation
|
||
layers over hairlines, hover on every interactive element. Then apply the L1 kit to draw
|
||
it. New layout math goes in a pure geometry module; `bank_grid`/`tab_strip`/`mode_switch`
|
||
stay the pure source of truth for their own hit-testing. **L2 sequences after M11 merges**
|
||
so it inventories the actual landed buttons.
|
||
|
||
## L4 dock-panel button layout (top / bottom / footer re-home)
|
||
|
||
L4 is a **second layout pass over the same `bank_panel`** that re-homes the L2 button inventory
|
||
around *frequency and intent*. It ships **no new action and changes no capture/placement
|
||
behavior** — every button fires an existing registered action; the "capture ≠ placement"
|
||
load-bearing principle is untouched (the buttons only *fire* the split acts, they never fuse
|
||
them). It is **ungated by Phase S** (the dock panel is on dev) and independent of L3.
|
||
**L4 sequences AFTER the in-flight DS-2 palette-revision branch merges to dev** — both rework
|
||
`bank_panel` heavily, and landing L4 concurrently would collide the same file. Drawn through the
|
||
L1 kit in the DS-2 grey-neutral + three-accent-pastel palette (prune stays `warn`); no palette
|
||
or font decisions are re-opened here.
|
||
|
||
**The new three-zone structure:**
|
||
|
||
- **Top toolbar = capture + placement + maintenance.** The capture cluster (capture item,
|
||
capture track, batch items, batch razor, capture RT), the placement cluster (insert,
|
||
insert-conform), and the maintenance cluster (re-capture, cancel-realtime) move from the
|
||
bottom L2 action bar to a **top** toolbar — the eye's first landing, matching the acts the
|
||
tool exists for. Icon+label buttons with the keybinding as a `micro` sub-label (the M11
|
||
keybinding-help convention), drawn through the kit.
|
||
- **Bottom toolbar = Design View tagging + switching.** The space the capture/placement buttons
|
||
vacate holds the **Design View action family** as buttons: **tag / untag selected tracks for a
|
||
mode, activate Arrange, activate Design, toggle active mode, show-both.** These are the
|
||
registered Design View actions today; L4 gives them a button home here.
|
||
- **Footer = narrow Arrange|Design toggle · Tail button · … · Prune.** The large top
|
||
`[ Arrange | Design ]` segmented toggle **shrinks to just-wide-enough-for-its-text** and moves
|
||
into the footer at the **left**, carrying its per-mode membership count as a compact adjacent
|
||
label. The **Tail** affordance is converted from a **click-zone to a proper kit button**
|
||
(rest/hover/pressed states; click still cycles the tail setting). **Prune** stays the
|
||
byte-deleting action — **set apart at the far right, `warn`-colored** — the only file-deleting
|
||
affordance, kept isolated so no benign toggle sits next to it.
|
||
|
||
**Footer affordance order (left → right):** `[Arrange|Design]` toggle · Tail button · … ·
|
||
**Prune** (rightmost, set apart, `warn`). The order reads benign/frequent at the left
|
||
(view-mode toggle, tail-length control — both "how this panel/capture behaves") → destructive/
|
||
rare at the right (Prune, isolated), so a mis-click near the left is cheap and the one
|
||
destructive control is spatially and chromatically distinct.
|
||
|
||
**Pure/shell discipline (unchanged).** All new toolbar-row and footer-strip layout math goes in
|
||
pure CTest-covered geometry modules — **extend/mirror `action_bar`** (the toolbar row layout,
|
||
now instantiated top and bottom) and **`mode_switch`** (the now-narrow, fit-to-text footer
|
||
toggle geometry + hit-test). `bank_grid` and `tab_strip` remain the pure owners of their own
|
||
surfaces' hit-testing. The L1 kit draws; the geometry stays pure. **L4 resolves its re-home
|
||
against the post-palette-revision `bank_panel`** (build-time inventory once that branch is on
|
||
dev).
|
||
|
||
## L5 dock-panel button refinements (overflow menu · faces+tooltips · opposite-mode tags · Toggle removal · grouping)
|
||
|
||
L5 is a **third refinement pass over the same `bank_panel` toolbars L4 built** — it re-homes
|
||
and re-labels buttons for legibility; it **ships no new action and changes no capture/placement
|
||
behavior** (every button fires an existing registered action; "capture ≠ placement" is
|
||
untouched — the buttons only *fire* the split acts). **Ungated by Phase S** (the dock panel is
|
||
on dev) and **independent of the L3 gate.** Drawn through the L1 kit in the DS-2 grey-neutral +
|
||
three-accent-pastel palette (prune stays `warn`); **no palette or font decision is re-opened.**
|
||
L5 sequences AFTER L4 (both rework the same `bank_panel` toolbars).
|
||
|
||
**1. Top-toolbar overflow menu.** The less-frequent capture variants — **Batch Items, Batch
|
||
Razor, Capture RT, Cancel RT** — leave the visible top bar for a **right-anchored "⋯ / More"
|
||
menu button** (kit-drawn button; on click a `TrackPopupMenu` popup lists all four, each entry
|
||
firing its existing command id via `Main_OnCommand`). The frequent acts stay on the bar in
|
||
cluster order Capture → Maintenance → Placement: Capture Item, Capture Track (Capture);
|
||
Re-capture (Maintenance); Insert, Insert Conform (Placement). The menu button's rect +
|
||
hit-test is **pure** (extend/mirror `action_bar` / `prune_button`); the popup + dispatch is
|
||
shell. The menu entries reuse the same `resolveBarCommandId` path the bar buttons use, so a
|
||
keybinding and a menu pick fire identically.
|
||
|
||
**2. Short faces + full-name tooltips (drop the `ReaSampler:` prefix).** Button *faces* carry
|
||
the terse `ActionBarRow.shortLabel` (already the case since L4) — L5 formalizes the face set and
|
||
adds a **hover tooltip showing the FULL action name with the `ReaSampler:` display prefix
|
||
stripped.** Note the prefix (`actionDisplayPrefix()`) is baked into the *registered gaccel
|
||
action name*, not the button face — so the tooltip derives the full name and strips the prefix
|
||
for display; the button face never carried the prefix. The keybinding is surfaced in the
|
||
hover tooltip ("`phrase — binding`" when bound, bare phrase when unbound) via
|
||
`kbd_getTextFromCmd`; the `micro` sub-row is removed from `ActionBarSlot`. **Tooltip mechanism: custom LICE-kit hover-delay tooltip** (`tooltip` pure
|
||
module) — owns its own hover timer + LICE overlay draw, stays inside the L1 kit (DS-1 "keep
|
||
drawing in the kit"). Sourced from the registered action phrase (not `kbd_getTextFromCmd`);
|
||
`ReaSampler:` prefix stripped at draw time; tooltip box width clamped to the client so it never
|
||
overhangs a narrow dock. The hover-timer threshold + overlay draw are the only DAW-bound pieces;
|
||
the "which button, what text" decision stays pure.
|
||
|
||
**3. Bottom-toolbar Item/Track × Arrange/Design tag buttons, opposite-mode-only.** The current
|
||
`Tag Design` / `Untag` pair is replaced by **four buttons — "Item: Arrange", "Item: Design",
|
||
"Track: Arrange", "Track: Design"** — in the Tagging cluster. **Both action families already
|
||
exist in the model + actions layer** (research-confirmed): the *track* family is
|
||
`doTag`/`doUntag` on the track selection (`VIEW_TAG_DESIGN` and `VIEW_TAG_ARRANGE`, where
|
||
Tag→Arrange == untag); the *item* family is `doMoveItems` on the item selection
|
||
(`VIEW_MOVE_ITEMS_DESIGN` / `VIEW_MOVE_ITEMS_ARRANGE`, driving `planItemRetag` + lane minting).
|
||
**So the four buttons are layout + enablement wiring over existing actions — NOT new feature
|
||
work; no `view_mode_model` / `view` / `actions` change is required.**
|
||
|
||
**Enablement rule (precise).** Let `active` = `view().activeModeId()` (the SAME read the footer
|
||
`[Arrange|Design]` toggle uses — one source of truth). A tag button's *target mode* is the mode
|
||
in its label (Arrange or Design). A button is **live iff target ≠ active**; otherwise it is
|
||
drawn `Disabled` (kit `InteractionState::Disabled`, `Role::TextDim`) and its click is a no-op.
|
||
Concretely:
|
||
- **Design active** → `Item: Arrange` and `Track: Arrange` are live (they send the selection to
|
||
Arrange); `Item: Design` and `Track: Design` are disabled (the selection is already there).
|
||
- **Arrange active** → the reverse: the `…: Design` buttons are live; the `…: Arrange` buttons
|
||
are disabled.
|
||
The disabled predicate is **pure** (active mode → per-button live/disabled, unit-tested); the
|
||
shell reads the active mode once per draw and applies it. Item buttons act on the current media-
|
||
item selection; Track buttons act on the current track selection — matching the existing action
|
||
bodies exactly (no selection semantics change).
|
||
|
||
**4. Toggle button removed.** `VIEW_TOGGLE_MODE` leaves the bottom toolbar — the footer's
|
||
`[Arrange|Design]` toggle (L4) already covers mode switching. **The action stays registered**
|
||
(keybinding-bound, FOREVER-STABLE id unchanged); only its *button home* is removed.
|
||
|
||
**Fate of Activate-Arrange / Activate-Design / Show-Both (FORK — RESOLVED, Daniel's call).**
|
||
- `VIEW_ACTIVATE_ARRANGE` / `VIEW_ACTIVATE_DESIGN` — **dropped from the bottom toolbar** (actions
|
||
stay registered, FOREVER-STABLE ids unchanged). The footer `[Arrange|Design]` toggle is the
|
||
single mode-switch affordance; the bottom bar is tagging + Show Both only.
|
||
- `VIEW_SHOW_BOTH` — **kept** as a set-apart button on the bottom toolbar. It is the cross-mode
|
||
"pin visible in every mode" escape hatch and is not covered by the footer toggle.
|
||
- **Landed bottom-toolbar inventory:** `[ Item: Arrange | Item: Design ] · [ Track: Arrange |
|
||
Track: Design ] ⟩⟩ [ Show Both ]` — Tagging cluster (four opposite-mode buttons) set apart
|
||
from a lone `Show Both`. No Toggle, no Activate-Arrange/Design (footer toggle owns switching).
|
||
|
||
**5. Semantic-grouping spacing.** L4's `kBarSpec` is `buttonGap=4` / `clusterGap=16` (4:1). With
|
||
the bottom bar's cluster boundary now more meaningful (four tag buttons vs. the `Show Both`
|
||
remnant) the grouping should read at a glance — **widen the inter-cluster gap: start ≈
|
||
`clusterGap=24` / `buttonGap=4` (6:1), tuned in-DAW.** One `kBarSpec` still serves both
|
||
toolbars (identical button shape top and bottom); only the gap ratio changes.
|
||
|
||
**Pure/shell discipline (unchanged).** All new geometry — the top-bar overflow menu-button rect
|
||
+ hit-test, the opposite-mode enablement predicate, the wider grouping spacing — goes in pure
|
||
CTest-covered modules (extend/mirror `action_bar` / `prune_button`; add a pure enablement
|
||
predicate). The `TrackPopupMenu` popup, the `Main_OnCommand` dispatch, and the tooltip hover-
|
||
timer + LICE overlay draw are the only DAW-bound pieces; the L1 kit draws. **L5 resolves its
|
||
menu/tooltip/action-id specifics against the landed L4 `bank_panel`** (build-time confirmation).
|
||
|
||
## L7 capture ordering · card metadata · selection styling (ordering model · overlay · tertiary-border selection)
|
||
|
||
L7 is the **next ungated dock-panel pass over the same `bank_panel` grid** L4–L6 built. Unlike
|
||
L4/L5 (pure layout over existing actions), **L7 (1) is a persisted-model change** — it adds an
|
||
explicit per-sample display order with gap-preserving sparse placement to the persisted bank
|
||
state and its JSON round-trip. (2) and (3) are draw-only. **No new capture/placement behavior;**
|
||
the "capture ≠ placement" principle is untouched. Drawn through the L1 kit in the DS-2
|
||
grey-neutral + three-accent-pastel palette; **no palette/font decision is re-opened.** Ungated
|
||
by Phase S; independent of the L3 gate. Sequences AFTER L6.
|
||
|
||
**Research baseline (dev, confirmed at spec time).** The grid draws in **`BankIndex` insertion
|
||
order**: `BankIndex` holds `std::vector<Sample> samples_` in insertion order (`all()`), and
|
||
`bank_grid::computeCellRects` tiles exactly one contiguous rect per sample left-to-right,
|
||
top-to-bottom — **no explicit ordinal, no sparse slots today.** `Sample` already carries
|
||
`lengthSeconds`, `lengthBeats`, and `captureTempo` (BPM at capture) but **no time-signature
|
||
field.** The selected cell is drawn (`bank_panel::drawThumbnail`) as
|
||
`InteractionState::Active` (accent-fill surface) + an `accent/primary` border + an *inverted*
|
||
waveform (`bg/base`), with focus as a `text/primary` double-line ring. Drag today: press on a
|
||
selected cell arms a drag; crossing a threshold begins it; leaving the client rect hands off to
|
||
OS drag-out (`drag_out::decideGesture`); a drop on a tab / the other region moves (or copies on
|
||
Ctrl) the samples to that bank. **There is no same-bank in-grid reorder today.**
|
||
|
||
### 1. Persisted deterministic order + sparse (gap-preserving) placement
|
||
|
||
Each bank (and the pool) carries an **explicit, persisted display position per sample** — the
|
||
grid no longer derives order from insertion order. Positions are **gap-preserving**: a sample
|
||
may occupy a slot that leaves earlier slots empty (an empty first row above an occupied second
|
||
row is a valid, persisted state).
|
||
|
||
**Where the position data lives (the CLAUDE.md constraint).** `bank_model` / `Sample` are stated
|
||
**untouched by `bank_book`'s wrapping** (no `bankId` on `Sample`; a bank is a logical grouping
|
||
over the shared pool). Display position is a **per-bank display concern**, so it belongs with the
|
||
bank's membership, **not on `Sample`** — a copy of a sample into two banks can sit at different
|
||
slots. **Recommended carrier: a per-`Bank` ordered position map in `bank_book`** (sample id →
|
||
slot), leaving `bank_model` untouched. The exact carrier (ordered id list with gaps vs. explicit
|
||
id→slot map) is settled at build; the *contract* below holds either way.
|
||
|
||
**Ordering contract (deterministic, gap-preserving):**
|
||
- **Deterministic:** the grid iterates positions in ascending slot order; ties are impossible
|
||
(one sample per slot). The order is fully determined by the persisted position data, not by
|
||
insertion order or hash.
|
||
- **Insert (new capture):** a new capture takes the **next free slot after the last occupied
|
||
slot** (append). It never fills an earlier gap automatically — a gap is a user's deliberate
|
||
layout, not a hole to be plugged.
|
||
- **Delete / remove / prune:** removing a sample **leaves its slot empty** (does not re-pack) so
|
||
every other sample keeps its position. Define at build whether a purely-empty trailing tail is
|
||
trimmed for content-height purposes (the recommendation: trim trailing empties for scroll
|
||
extent, keep interior gaps).
|
||
- **Reorder:** the user drags a card to a target slot within its bank; the pure reorder mutator
|
||
moves that sample's position to the target slot, gap-preserving. (Whether a drop onto an
|
||
occupied slot displaces/swaps vs. inserts-and-shifts is settled at build — recommendation:
|
||
**drop-into-empty-slot places there; drop-onto-occupied inserts before and shifts the tail**,
|
||
matching common file-manager reorder.)
|
||
|
||
**JSON round-trip + migration (load-bearing).** `serialize`/`deserialize` stay lossless
|
||
including positions (`deserialize(serialize(x)) == x`). **A pre-L7 project blob has no position
|
||
data** → on load it defaults to **current insertion order, densely packed (no gaps)**, so a
|
||
project saved before L7 is visually identical on first post-L7 load. This default is the
|
||
migration; it is one-way (once re-saved, the position data is authoritative). Pure and
|
||
hard-tested — this is a persisted-model change, tested to the same bar as `bank_book`'s existing
|
||
round-trip + legacy migration.
|
||
|
||
**Undo.** A reorder is **one Ctrl-Z** — the actions/shell layer wraps the mutation in a batched
|
||
undo point (`Undo_BeginBlock2`/`EndBlock2`), matching every existing bank-verb's undo discipline.
|
||
|
||
**M9 overlap (flagged — Daniel's awareness).** M9 "slots" (capture-to-slot-N / insert-slot-N,
|
||
MIDI-bindable, MPC-style) is **explicitly deferred (Daniel, 2026-07-26).** L7's sparse-slot
|
||
model is a **partial overlap:** it builds the *addressable-position substrate* M9 would sit on,
|
||
but L7 adds **no** slot-numbered capture/insert actions and **no** MIDI bindings. Building L7
|
||
un-defers the *coordinate model* portion of M9, not the *action* portion. If Daniel wants the
|
||
substrate to be explicitly M9-shaped (fixed numbered slots vs. a plain gap-preserving ordinal),
|
||
that is a fork to settle before the model is built.
|
||
|
||
### 2. Decorative metadata overlay (bars.beats · s.ms)
|
||
|
||
Each card overlays, on the waveform, two decorative read-outs of **capture length**:
|
||
- **bottom-LEFT:** length in **bars.beats.subdivisions** (musical).
|
||
- **bottom-RIGHT:** length in **seconds.milliseconds** (wall-clock).
|
||
|
||
**Contract:** decorative and **non-interactive** — no hit-test, no hover, no selection role.
|
||
Drawn via the L1 kit `text()` in the **micro / value-mono** type class, in `text/dim` (or a
|
||
subtle shadowed variant for legibility over the peaks), subordinate to the waveform. **Respects
|
||
the speed constraint (no animation).** Pure formatting helpers (below) are unit-tested; only the
|
||
kit draw is shell.
|
||
|
||
**Bars.beats source (FORK F1 — Daniel to confirm).** bars.beats.subdivisions requires a
|
||
**tempo + time-signature** reference. `Sample` already carries `captureTempo` (BPM at capture)
|
||
and `lengthBeats`, but **no time-signature.** Two options:
|
||
- **(recommended) Capture-time stamp:** add `captureTimeSigNum` / `captureTimeSigDenom` to
|
||
`Sample` + its JSON round-trip, stamped on the capture path (read the project meter at capture
|
||
via the REAPER meter API — verify `TimeMap_GetTimeSigAtTime` / equivalent against the SDK at
|
||
build). *Why recommended:* the label is **stable** as the project's tempo/meter later changes;
|
||
a bank sample outlives the project state it was captured under, matching the existing
|
||
`captureTempo` stamp philosophy. **Cost:** this is a **capture-path write beyond draw work** —
|
||
its own flagged checkbox; old samples with no stamp fall back gracefully (blank musical
|
||
read-out, or a documented assumed 4/4).
|
||
- **(rejected) Live project meter at draw time:** the label would drift under the card as the
|
||
project tempo/meter changes, and would be wrong for any sample captured under a different
|
||
meter than the project's current one.
|
||
|
||
**Formatting helpers (pure, tested).** `bars.beats.subdivisions` from `lengthSeconds` +
|
||
`captureTempo` + capture-time signature; `seconds.milliseconds` from `lengthSeconds`.
|
||
Deterministic; graceful on edge cases (zero length → both read empty/`0`; missing tempo → blank
|
||
musical read-out, keep the s.ms read-out).
|
||
|
||
### 3. Selection styling — tertiary border replaces inversion
|
||
|
||
A selected card **drops the inverted accent-fill treatment** and instead draws the **normal
|
||
cell** (Rest or Hover surface) + an **`accent/tertiary` border** (pastel purple `#C2AAE8`); the
|
||
**waveform draws in its normal accent color** (the inverted `bg/base` wave is removed). The four
|
||
grid-card interaction states stay **visually distinct and coherent:**
|
||
- **Selected:** normal cell surface + **`accent/tertiary` (purple) 1px border**. No fill change,
|
||
no wave inversion.
|
||
- **Focus (caret):** the existing distinct inner ring — keep it separate from the selection
|
||
border so a focused *and* selected card reads both (e.g. purple outer border + a
|
||
`text/primary` inner focus ring). Settle the exact inner treatment at build so focus is
|
||
legible on top of the selection border.
|
||
- **Hover:** the kit Hover surface (unchanged) — a fill-state change, orthogonal to the purple
|
||
border, so a hovered selected card still reads as selected.
|
||
- **Drag-target slot:** the reorder drop-target highlight (a distinct accent — recommend
|
||
`accent/hot` outline on the target slot) must not be confusable with the purple selection
|
||
border; spec the exact treatment at build.
|
||
|
||
### Drag disambiguation (FORK F3 — proposed rule, Daniel to confirm)
|
||
|
||
In-grid reorder must coexist with the existing internal bank-move/copy drag and OS drag-out.
|
||
**Proposed precedence (one clean rule, evaluated live during the drag):**
|
||
1. **Pointer leaves the client rect → OS drag-out** (unchanged; `drag_out::decideGesture` wins
|
||
first — the existing invariant #4 boundary).
|
||
2. **Else drop lands on a tab / the OTHER region's bank → move/copy** (unchanged; Ctrl = copy).
|
||
3. **Else drop lands within the SAME bank's own grid → reorder-to-slot** (new).
|
||
|
||
So: leave-client wins → else other-bank wins → else same-bank-grid = reorder. The precedence is
|
||
encoded in a **pure decision helper** (mirror `drag_out::decideGesture`); the shell reads the
|
||
live pointer + focused region + client rect and calls it. This keeps the reorder gesture from
|
||
ever stealing an intended bank-move or OS-drag, and keeps a same-bank in-grid drag from being
|
||
mis-read as a no-op (today a same-bank drop is a no-op; L7 gives it reorder meaning).
|
||
|
||
### Pure/shell discipline (L7)
|
||
|
||
Model: the position carrier + gap semantics + reorder mutator + JSON round-trip/migration are
|
||
**pure** (in `bank_book`, CTest-covered to the bar of its existing round-trip). Layout: the
|
||
sparse-aware slot↔rect math + point→slot hit-test + the drag-disambiguation decision are **pure**
|
||
(extend `bank_grid`; mirror `mode_switch` / `drag_out::decideGesture`). Formatting: the
|
||
bars.beats and s.ms formatters are **pure**. Shell (DAW-bound): the reorder drag wiring +
|
||
drop-target highlight, the capture-time-signature stamp (F1) read on the capture path, and the
|
||
kit overlay/selection-border draw. The L1 kit draws; no palette/font decision re-opened.
|
||
|
||
## The L3 gate + Phase S coordination contract
|
||
|
||
**L3 (VST editor + embed-strip restyle) is GATED on Phase S landing on dev.** The VST
|
||
editor (`IPlugView` LICE surface), the S6 embed strip, and the keyboard strip live in
|
||
Phase S, which is **not on dev yet** (it exists on the phase-s worktree). L3 cannot be
|
||
built on dev until Phase S's editor/embed surfaces (≈ S1 / S6 / S10) merge to dev — **the
|
||
Phase L team must not chase these files on dev; they are not there.** Until then L3 is a
|
||
planned, blocked point; L1, L2, L4, and **L5 have all landed** — see `COMPLETED.md`. L4 and L5
|
||
are independent of the L3 gate.
|
||
|
||
**Coordination contract (load-bearing):** Phase S's S10–S13 build their interaction UX with
|
||
the **current** drawing and **adopt the L1 kit when it is available — they are NOT gated on
|
||
Phase L.** Whichever lands first (the L1 kit or the S10–S13 UX), the kit is the **one source
|
||
of drawing**: if S10–S13 reach dev before L1, they draw in the current language and L3
|
||
restyles them; if L1 lands first, they are born in the kit. Either way there is one kit and
|
||
one look; L3 completes the VST/embed adoption and applies the settled-and-revised **B +
|
||
three-accent pastel** treatment (with C's **pastel** spectral keyboard strip as the
|
||
signature surface), routing text through the kit's cached-font `text()` (§3.1 — the kit's
|
||
current face; no font change). The VST3 class UID is unchanged — a visual refresh is not a
|
||
compat event.
|
||
|
||
## Precision / invariant implications (what Phase L does NOT change)
|
||
|
||
- **The pure/shell split holds.** All layout/hit-test stays in pure CTest-covered geometry
|
||
modules; the kit's *draw* half is shell, its *geometry* half is pure — even where a WDL
|
||
piece is reused (DS-1). No hit-test math moves into untestable code.
|
||
- **RT discipline untouched.** The kit is draw-thread only; nothing here touches `process`
|
||
or any off-thread reload handoff (a Phase S concern surfaced at L3).
|
||
- **Read-only-over-bank untouched.** This is look-and-feel; no data-ownership change.
|
||
- **The capture/placement load-bearing principle is untouched.** Phase L draws; it does not
|
||
capture, place, or mutate the bank.
|
||
- **VST3 class UID / component-state contract unchanged** (Phase S concern; noted for L3).
|
||
- **Windows-only (D5).** Font/GDI/HFONT choices assume Windows; no cross-platform font
|
||
fallback concern.
|
||
|
||
## LICE / WDL API surface (verify all signatures)
|
||
|
||
- **LICE design-kit surfaces (L1).** `LICE_GradRect`, `LICE_RoundRect`, AA
|
||
`LICE_Line`/`LICE_FLine`/`LICE_ThickFLine`/`LICE_Circle`/`LICE_FillCircle`/
|
||
`LICE_DrawCBezier`, `LICE_FillTriangle`/`FillTrapezoid`/`FillConvexPolygon`, and the
|
||
`LICE_CachedFont`/`LICE_IFont` font engine (`SetFromHFont`, AA `DrawText`, shadow/outline/
|
||
glow FX flags). Verified *present* in `vendor/WDL/WDL/lice/lice.h` + `lice_text.h`;
|
||
**confirm exact signatures + the `LICE_CachedFont`↔`HFONT` lifecycle at build.**
|
||
- **WDL/vwnd reuse (DS-1, build-time assessment).** `virtwnd-slider.cpp` /
|
||
`vwnd_slider_drawknobstack` (slider/knob drawing reference), `virtwnd-listbox.cpp`
|
||
(candidate scroll listbox), `virtwnd-controls.h` (`WDL_STYLE_*` gradient hooks),
|
||
`virtwnd-skin.h` (image-skin helpers) — all in `vendor/WDL/WDL/wingui/`. Reuse where a
|
||
piece beats re-deriving; keep hit-test geometry pure regardless.
|
||
- **Panel drawing/hit-test (L2).** Reuses the `bank_panel` LICE surface + the existing pure
|
||
`bank_grid`/`tab_strip`/`mode_switch` hit-test modules; the new action-bar layout is a new
|
||
pure module. `WM_MOUSEMOVE`/`TrackMouseEvent` (`WM_MOUSELEAVE`) for hover on the panel's
|
||
existing timer-driven `wndProc`. Verify against the SWELL headers as `bank_panel` already
|
||
does.
|
||
|
||
## Non-goals / guardrails
|
||
|
||
- **No external UI framework.** iPlug2 / JUCE / VSTGUI are rejected (DS-1). LICE + reused
|
||
WDL pieces are the toolkit; reject any path that pulls in a new framework.
|
||
- **No hit-test geometry in untestable shell code.** Even when reusing a WDL piece, layout/
|
||
hit-test math stays in pure CTest-covered modules (DS-1 caution). Reject a control whose
|
||
adoption would move geometry into the shell without a pure test seam.
|
||
- **L2 does not restructure the panel bones.** The vertical-split / grid / tab structure is
|
||
sound and stays; L2 designs the *layout of the button inventory around it* (DS-3). A
|
||
ground-up structural rework of landed Phase-B panel structure is out of scope.
|
||
- **L3 does not build on dev until Phase S lands there.** The gate is explicit; do not chase
|
||
Phase S files on dev.
|
||
- **A visual refresh is not a compat event.** VST3 class UID, command-id strings, ext-state
|
||
namespaces, and component-state contracts are unchanged by Phase L.
|
||
- **Verify LICE/WDL/SWELL surfaces** against `vendor/WDL` before use.
|
||
|
||
---
|
||
|
||
# Structural reorganization — reorg spec (Phase Q — Quality)
|
||
|
||
> **New pillar, own lettered phase, and the LAST structural pillar.** Phase Q is a **pure
|
||
> structural refactor** of `src/` — more encapsulation, granular namespaces, `core/`/`shell/`/
|
||
> `app/` subdirectories — against a stated quality bar (*"mtytel Vital is my code reference for
|
||
> quality"*), to bring the codebase "into the realm of something I can stand to look at." It
|
||
> **ships no feature and changes no behavior**: the test suite passing unchanged is the proof of
|
||
> correctness. Namespaced **`Q` (Quality)** — M/D/B/R/V/S/L taken; `Q` names the end (the quality
|
||
> bar), the reorg being the means. Product framing, the Vital-grounded target shape, the
|
||
> grep-verified SOLID audit (the evidence base), and the fork record (Q-1..Q-6):
|
||
> `docs/product/code-organization.md`. When a point lands, doc-keeper moves it to `COMPLETED.md`.
|
||
|
||
## What it is
|
||
|
||
A directory + namespace + file-split reorganization that makes ReaSampler's **already-real,
|
||
CMake-enforced** architecture **legible in the code's shape**. The pure/shell split exists (30
|
||
pure static libs, each with its own test executable, none linking a REAPER SDK) but is invisible:
|
||
all 45 files sit in **one flat `src/`**, all 37 headers in **one flat `reasampler` namespace**,
|
||
four modules have grown into god-modules, and the JSON parser is copy-pasted across four models.
|
||
Phase Q gives the existing subsystem grouping — model / view / capture / audio / ui / reclaim /
|
||
version — a **structural home** (directories + namespaces), splits the four god-modules along
|
||
validated seams, and extracts the duplicated JSON into one pure module. Nothing about the
|
||
architecture *changes*; it becomes *visible*. This is why the phase can be zero-runtime-cost and
|
||
CTest-green throughout: the seams already exist in the link graph; Phase Q draws them where a
|
||
reader sees them.
|
||
|
||
## The quality bar — Vital (read from its actual `src/` tree)
|
||
|
||
Vital (`github.com/mtytel/vital`) groups its synth by **subsystem** — `common/` `synthesis/`
|
||
`interface/` `plugin/` — with `synthesis/` further subdivided by function (`synth_engine/
|
||
modulators/ filters/ effects/ producers/ framework/ lookups/ utilities/`). The grouping principle
|
||
is **layered functional architecture**: the directory tree *is* the architecture diagram; you read
|
||
the subsystem map off the folders. ReaSampler adopts the *pattern* (directory = architecture),
|
||
adapted to its own most load-bearing invariant — the pure/shell split — as the top level (see
|
||
below). Vital is GPLv3; the borrowed artifact is the **structural pattern**, not code.
|
||
|
||
## Settled decisions (Q-1 settled; Q-2..Q-6 recommended — see `docs/product/code-organization.md` §6)
|
||
|
||
- **Q-1 — namespace letter. SETTLED: `Q` (Quality).** Point-id family `Q1..Qn`, wave prefixes
|
||
`Q-W1..Q-W6`. `O` (Organization) was set aside: the glyph reads ambiguously against zero in
|
||
point ids, and "Organization" undersells a phase measured against a *quality* bar.
|
||
- **Q-2 — JSON extraction in scope + first. REC: yes.** The 4× duplicated `Parser` is the largest
|
||
DRY+SRP violation and is entirely off the hot paths — the ideal safe, high-leverage opener
|
||
(Q-W1).
|
||
- **Q-3 — directory shape. REC: `core/`/`shell/`/`app/` top-split, subsystem dirs beneath.** Top
|
||
level by the pure/shell discipline (so the invariant is *structural*, not merely conventional),
|
||
subsystem grouping one level down. Preferred over pure-Vital subsystem-first because ReaSampler
|
||
has a pure/host split Vital lacks and that split is the invariant most worth protecting
|
||
structurally.
|
||
- **Q-4 — sub-namespace to match sub-directory. REC: both.** `reasampler::model`/`view`/`capture`/
|
||
`audio`/`ui`/`reclaim`/`version`/`json`. Directory and namespace agree; a symbol's home is
|
||
unambiguous from either.
|
||
- **Q-5 — god-module split granularity. REC: to the audit's named seams, no finer.** Well-factored,
|
||
not atomized.
|
||
- **Q-6 — OCP registration-table. REC: in scope, last (most droppable if narrowing).**
|
||
|
||
## The directory + namespace map (Q-3 / Q-4)
|
||
|
||
Top-level by the pure/shell discipline; subsystem dirs beneath `core/`; namespaces mirror
|
||
directories.
|
||
|
||
**`core/` (pure, no REAPER types, unit-tested — `reasampler::<subsystem>`):**
|
||
- `core/model/` (`::model`) — `bank_model`, `bank_book`, `owned_manifest`, `provenance`
|
||
- `core/view/` (`::view`) — `view_mode_model`, `view_tree`, `lane_keys`, `mode_switch`
|
||
- `core/capture/` (`::capture`) — `render_settings`, `batch_capture`, `tail_control`,
|
||
`capture_paths`, `wav_trim`, `insert_plan`
|
||
- `core/audio/` (`::audio`) — `peaks`
|
||
- `core/ui/` (`::ui`) — `theme`, `component_geometry`, `bank_grid`, `tab_strip`, `action_buttons`,
|
||
`prune_button`
|
||
- `core/reclaim/` (`::reclaim`) — `prune_reconcile`
|
||
- `core/version/` (`::version`) — `app_version`
|
||
- `core/json/` (`::json`) — **NEW** — extracted parser/serializer (replaces the 4 duplicate
|
||
`Parser`s)
|
||
|
||
**`shell/` (REAPER-facing — subdir by subsystem, namespace as house style prefers):**
|
||
- `shell/capture/` — `capture`, `capture_realtime`, `provenance_shell`, `track_guid`, `item_read`;
|
||
**post-Q-W3** `capture_orchestrator`, `scope_resolve`, `realtime_lifecycle`
|
||
- `shell/panel/` — `draw_kit`; **post-Q-W2** `panel_render`, `panel_thumbnails`,
|
||
`panel_audition`, `panel_input`, `panel_bank_ops`, `panel_window` (from `bank_panel`)
|
||
- `shell/view/` — `view`
|
||
- `shell/persist/` — **post-Q-W5** `session`, `ext_state_io`, `prune_fs` (from `persist`)
|
||
- `shell/actions/` — `drag_out_win`; **post-Q-W4** `design_view_actions`, `bank_actions`,
|
||
`prune_action` (from `actions`)
|
||
|
||
**`app/`:** `main.cpp` (post-Q-W3: API-pointer ownership + `ReaperPluginEntry` + dispatch only).
|
||
|
||
**Collision sweep (before W1):** `Sample` (`::model`) vs `AudioSample` (`::audio` alias) vs the
|
||
unified `Parser` (`::json`) must not collide once flattened into granular namespaces; resolve by
|
||
subsystem home.
|
||
|
||
## The god-module split seams (Q-5)
|
||
|
||
Split each to the audit-validated seams, no finer:
|
||
|
||
- **`bank_panel.cpp` (2424 LOC → `shell/panel/`, Q-W2):** `panel_render` (draw/paint) /
|
||
`panel_thumbnails` (compute+cache) / `panel_audition` (preview engine — **hot path, direct
|
||
call-through**) / `panel_input` (mouse/key/wheel + new-content detection) / `panel_bank_ops`
|
||
(bank CRUD — the single owner W4 dedupes against) / `panel_window` (lifecycle + OS
|
||
drag-out/drop-target). Split the fat `bank_panel.h` per seam (I).
|
||
- **`main.cpp` (1762 LOC → hoist to `shell/capture/`, Q-W3):** `capture_orchestrator`
|
||
(`RunCapture`/`captureAndIndexOne`/`renderOffline`/batch/recapture/realtime `Run*`) /
|
||
`scope_resolve` (range/razor/track resolution + provenance assembly inputs) /
|
||
`realtime_lifecycle` (state machine + globals + selection guards). `FxBypassGuard` moves out
|
||
but **stays stack RAII** (precision-critical). `main.cpp` → `app/`, reduced to pointers + entry
|
||
+ dispatch.
|
||
- **`actions.cpp` (981 LOC → `shell/actions/`, Q-W4):** `design_view_actions` /
|
||
`bank_actions` / `prune_action` (`doBankPruneFolder` — the single file-deletion action). Dedupe
|
||
`promptText`/`mintBankId` + bank verbs against `panel_bank_ops`.
|
||
- **`persist.cpp` (766 LOC → `shell/persist/`, Q-W5):** `session` (lifecycle/poll +
|
||
`BeginLoadProjectState` reload hook) / `ext_state_io` (serialization bridge + GUID minting +
|
||
folder relocation) / **`prune_fs`** (prune scan + `deleteOrphanFile` via `SHFileOperationW` —
|
||
the isolated single file-deletion authority).
|
||
|
||
## The JSON extraction (Q-2 / Q-W1)
|
||
|
||
Extract one pure **`core/json`** (`::json`): parser (`parseString`/`parseInt`/`parseKey`/
|
||
`skipValue` + escape) + serialize/emit helpers. Rewire `bank_model`, `bank_book`,
|
||
`view_mode_model`, `owned_manifest` onto it and **delete their four hand-rolled `Parser`s**.
|
||
Round-trip output must be **byte-identical** to before — this is a structural dedupe, not a format
|
||
change. Off all hot paths (serialization runs at save/load, never per frame) — safe to abstract
|
||
freely.
|
||
|
||
## The OCP registration-table (Q-6 / Q-W6)
|
||
|
||
Replace the ~350-line hand-written **non-table** action registration blocks (isolated in
|
||
`app/main.cpp` after Q-W3) with a **data-driven registration table**, so adding an action edits one
|
||
place, not four parallel `Register("command_id"/"gaccel"/"hookcommand")` sites. Unload
|
||
mirror-unregisters from the same table. Command-id + display strings stay **byte-identical**
|
||
(FOREVER-STABLE, per-channel — the Phase V V4 contract). Also split residual fat headers
|
||
(`capture.h`/`persist.h`) alongside their TU splits (I).
|
||
|
||
## Performance guardrails (HARD CONSTRAINT — Daniel's non-negotiable)
|
||
|
||
The reorg must cost **zero runtime.** The two hot paths must keep their exact call/inline shape;
|
||
the following are acceptance criteria on every point:
|
||
|
||
- **`peaks` envelope compute** (`computeEnvelope` / `lastFrameAboveThreshold` over full PCM): **NO
|
||
virtual dispatch, NO `peaks` interface, NO added header→TU indirection.** `computeEnvelope`
|
||
stays a **free function on `const std::vector<float>&`** so it inlines as today. Relocate +
|
||
namespace only; never wrap in an abstraction.
|
||
- **Audition / preview**: `panel_audition` may be its own TU (Q-W2), but the call stays a **direct
|
||
call-through, not virtual.**
|
||
- **Realtime-capture tick**: keep the idle fast-path a **single pointer test**;
|
||
`realtime_lifecycle` (Q-W3) must not change the tick's branch shape.
|
||
- **JSON extraction** is **off all hot paths** — safe to abstract freely (why Q-W1 is the safe
|
||
opener).
|
||
- **`FxBypassGuard`** runs per-capture, not per-frame — keep it **stack RAII** when it moves out
|
||
of `main.cpp` (Q-W3); never heap-allocate or virtualize it.
|
||
|
||
**Net:** every recommended split falls on a cold path or preserves call/inline shape on the two
|
||
hot ones. *A split that would add a hot-path indirection is out of scope — rework it or drop it.*
|
||
|
||
## The GATE (load-bearing — Phase Q is last)
|
||
|
||
Phase Q is **gated on the tree being otherwise quiescent.** It does not begin until **Phase S has
|
||
merged to dev**, **Phase L (L2 + L3) has merged to dev**, any **D2 residuals** are closed, and
|
||
**M9** is landed-or-abandoned. *Why:* Phase Q touches **nearly every file in `src/`** (relocate,
|
||
re-namespace, split the four largest TUs); every large in-flight branch (Phase S on its worktree,
|
||
Phase L's `bank_panel`-touching L2/L3) is diffed against the *current flat layout*, so landing a
|
||
rename-and-relocate-everything reorg mid-flight forces every open branch through the worst conflict
|
||
class — a combinatorial re-resolution, not a linear one. Phase Q is *last* precisely because it
|
||
reshapes the ground every other pillar stands on: landing it early taxes every subsequent phase;
|
||
landing it last taxes nothing. Re-confirm quiescence against dev before Q-W1.
|
||
|
||
## Wave sequencing (each independently landable, CTest-green at every step)
|
||
|
||
Big-bang is rejected — the CMake per-module static-lib + per-module test-executable seams make a
|
||
file move + namespace change **mechanically verifiable** (`ctest --test-dir build` green or not, at
|
||
every commit), a property only an *incremental* reorg uses. Risk-ordered:
|
||
|
||
- **Q-W1** — safe opener: `core/json` extract (delete 4 `Parser`s) + impose the directory/
|
||
namespace layout on the 30 clean pure libs + clean shells (pure relocation, no logic change).
|
||
All later waves assume this layout.
|
||
- **Q-W2..Q-W5** — the four god-module splits, one per wave, risk-ordered (`bank_panel` →
|
||
`main.cpp` → `actions.cpp` → `persist.cpp`). Q-W4 depends on Q-W2 (`panel_bank_ops` dedupe
|
||
target); Q-W5 best after Q-W4 (`prune_action` → `prune_fs` routing); otherwise parallel-safe.
|
||
- **Q-W6** — OCP registration-table + residual fat-header (I) splits. Depends on Q-W3 (registration
|
||
code isolated first). Sequenced last; most droppable if narrowing.
|
||
|
||
## Precision / invariant implications (what Phase Q does NOT change)
|
||
|
||
- **The pure/shell split is strengthened, never dissolved.** Top-level `core/` vs `shell/` makes
|
||
it *structural*; no file crosses the boundary; no `core/` file gains a REAPER type; the CMake
|
||
per-module test executables stay green.
|
||
- **Every precision invariant holds.** Null test, bit-identical repeats, non-destructive capture,
|
||
exact bounds — none is code Phase Q rewrites. `FxBypassGuard` moves but stays stack RAII with
|
||
identical behavior.
|
||
- **Capture ≠ placement.** No hoisted `Run*` / `capture_orchestrator` path may gain an
|
||
`InsertMedia` call during the split.
|
||
- **Single file-deletion authority is concentrated, never spread.** `prune_fs` (Q-W5) is the one
|
||
module that deletes bytes; the reorg improves this invariant.
|
||
- **Relative-paths-only** in the persisted index — untouched.
|
||
- **On-the-wire/on-disk contract strings unchanged.** Command-id strings, action display names,
|
||
ext-state namespaces, VST3 class UID are byte-identical (per-channel, per Phase V V4).
|
||
Re-namespacing C++ symbols is orthogonal to these.
|
||
- **No behavior change.** Phase Q is a pure structural refactor; the test suite passing unchanged
|
||
is the proof of correctness. A point that changes observable behavior has exceeded its charter.
|
||
|
||
## Non-goals / guardrails
|
||
|
||
- **No feature, no behavior change.** If a point ships anything user-visible, it is out of scope.
|
||
- **No hot-path indirection.** No virtual dispatch or header→TU indirection on `peaks`/audition/
|
||
realtime-tick — ever (a hard acceptance bar, not advice).
|
||
- **No design-level (D-letter SOLID) rework.** `main`/`bank_panel` depending on concrete capture
|
||
backends is a low-priority Dependency-Inversion concern — **out of scope** (a design change, not
|
||
a reorg). Phase Q reorganizes; it does not re-architect interfaces.
|
||
- **No `peaks` data-ownership change.** `peaks` forcing a whole-file `std::vector<float>` copy on
|
||
the thumbnail path is noted but **not touched** — reworking it risks the hot path.
|
||
- **No big-bang commit.** Every wave is independently landable and CTest-green; reject a change set
|
||
that cannot be verified at each step.
|
||
- **Do not begin before the GATE.** Re-confirm the tree is quiescent (Phase S + L + D2 + M9
|
||
merged/closed) before any Q point.
|
||
- **Verify** the CMake `src/` path updates and the SWELL/LICE surfaces still resolve after
|
||
relocation, as the existing build already requires.
|