Post-DAW-test: name the instrument ReaSampler 9000; spec the better-than-RS5K overhaul — S10 keyboard-strip keymap editor, S11 waveform + draggable loop points, S12 scroll/search/direct-entry/ADSR, S13 drop-to-load via the S8 relay. VST3 class UID locked unchangeable; S-NAME-1 (binary filename) is Daniel's call.
170 KiB
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— theSamplemetadata struct and theBankIndex(add / remove / query / tier moves / dedup-by-hash) plus JSON serialize/deserialize to astd::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— theICaptureBackendinterface plusOfflineRenderBackendandRealtimeRecordBackend. Input: aCaptureRequest(capture scope — item or track, time range, tail, SR/bit-depth/channels, output path). Output: a finished file + a populatedSamplehanded tobank_model. Capture is always wet; the FX scope (not a wet/dry dial) is the control — the purerender_settingsmodule maps a scope to its render-source bits and its FX-bypass plan (see §Capture FX scope below).insert— placement viaInsertMedia; 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 inmpe_view.cpp.persist— project ext state <->bank_modelJSON; 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 (thecommand_id+gaccel+hookcommandpattern frommain.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) andGetSetProjectInfo_String(RENDER_FILE,RENDER_PATTERN,RENDER_FORMAT). TheRENDER_SETTINGSbit 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_SETTINGSbit. 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=0so 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). VerifyI_RECMODEvalues 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 inUndo_BeginBlock2/Undo_EndBlock2.
Persistence & paths:
SetProjExtState/GetProjExtState(namespace e.g."reasampler") for the index JSON. Resolve project folder viaEnumProjects/GetProjectPathEx; store the bank under a project-relative subfolder; keep only relative paths in the index.
Build order (each milestone independently testable)
bank_model+ JSON round-trip + unit tests. (pure — no REAPER)peaks+ unit tests. (pure)- 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) persist: write the index to proj ext state, reload on project open; confirm it survives Save / Save As. (bank travels with the .rpp)bank_panel: docked grid with thumbnails, audition, selection.insert: "insert selected sample at edit cursor" action viaInsertMedia.- 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).
RealtimeRecordBackendbehind the same interface.- Slots: "capture to slot N" / "insert slot N", MIDI-bindable (MPC-style).
- Provenance + "re-capture from source"; null-test verify action.
- 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 againstvendor/reaper-sdk/sdk/reaper_plugin_functions.hbefore 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-sideI_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 indocs/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), andTrackFX_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_SHOWINMIXERon 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:
viewshell — readsI_FOLDERDEPTHacross the track list to build the parent↔child tree and feeds it toview_mode_model; applies the planner's operations viaSetMediaTrackInfo_Value(B_SHOWINTCP/B_SHOWINMIXER/B_MAINSEND/I_FXEN) andTrackFX_GetCount+ per-FXTrackFX_SetOffline; snapshots prior flag values before parking; resolves GUIDs viaGetTrackGUID/guidToString/stringToGuid. Never touches master visibility, never touchesB_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 thecommand_id/gaccel/hookcommandpattern; 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 citedB_SHOWINMCP; verified SDK name isB_SHOWINMIXER.) - Per-FX offline:
TrackFX_GetCount+TrackFX_SetOffline(track, fx, offline). - Folder tree: read
I_FOLDERDEPTHper track to derive parent↔child structure. - GUID keying:
GetTrackGUID,guidToString,stringToGuid. - Persistence:
SetProjExtState/GetProjExtStateunder"reasampler"(shared with the bank index — one blob, two logical sections). - Wrap flag mutations in
Undo_BeginBlock2/Undo_EndBlock2as 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_POSITIONwith mode semantics (timebase-offset regions) — the latter collides with the capture null test. Seedocs/product/design-view.md§Two-canvas direction for why those were rejected. - Never touch mute/solo. Any code path reading/writing
B_MUTE/I_SOLOis 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).
- FX-GUID keying for
- 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-sideI_FREEMODE = 2,I_NUMFIXEDLANES,C_LANEPLAYS:N; item-sideI_FIXEDLANE,C_LANEPLAYS,B_FIXEDLANE_HIDDEN.I_FREEMODEchanges requireUpdateTimeline()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_LANEPLAYSstays 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_statealongside 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_modelextension). Lane math — which lane maps to which mode, whichC_LANEPLAYSvalue 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_HIDDENvia the media-item info setters,UpdateTimeline()afterI_FREEMODEchanges) — 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.rppnatively.
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_FIXEDLANEis 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_FIXEDLANEis 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:NviaGetMediaTrackInfo_Value/SetMediaTrackInfo_Value; item:I_FIXEDLANE,C_LANEPLAYS,B_FIXEDLANE_HIDDENviaGetMediaItemInfo_Value/SetMediaItemInfo_Value. CallUpdateTimeline()afterI_FREEMODEchanges. - Detection reuses the
bank_paneltimer + GUID fingerprinting already in place; item GUIDs via the item'sGUID(GetSetMediaItemInfo_String"GUID"), track GUIDs viaGetTrackGUIDas 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 againstvendor/reaper-sdk/sdk/reaper_plugin_functions.hbefore 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
BankIndexamong 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
BankIndexper bank. A new pure modulebank_bookowns an ordered registry of banks, each bank ={ stable bank id, display name, ordinal, BankIndex }.BankIndexis untouched — the multi-bank layer wraps it, it does not modify it (additive; nobank-idfield onSample). 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/renameBankreject 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_bookis the mirror ofbank_modelandview_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_bookcarries 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'sBankIndex, 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
Samplefrom A'sBankIndex, 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
BankIndexdedups 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 semanticsBankIndex::addalready 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
banksin the existing"reasampler"namespace, alongsideview_stateandproject_guid. The pool's index rides inside thebanksblob as bank-zero — persisted identically to any named bank (one blob, one section, one JSON shape). Migration: on load, if abankskey is absent but a legacybank_indexkey is present, the legacy index is promoted into the pool inside a freshly-mintedbanksblob and the book is{ pool }with zero named banks — a one-way, lossless promotion. After migration thebanksblob is authoritative; the legacybank_indexkey 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
BankIndexin the book keeps the relative-path invariant at itsaddboundary — 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
banksblob 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 thebankskey in"reasampler"(pool-as-bank-zero inside the blob; no separatebank_indexkey going forward); migrate a legacybank_indexkey 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'sBankIndexis 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 thecommand_id/gaccel/hookcommandpattern; 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/GetProjExtStateunder"reasampler", new keybanks(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/hookcommandcontract frommain.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_bookwraps; 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_panelprose 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
Samplefrom aBankIndexand 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-banksseam, 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).
hashReferencedElsewhereis 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 againstvendor/reaper-sdkthat"reasampler"ext-state mutations participate correctly inUndo_BeginBlock/Undo_EndBlockundo 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 aSamplefrom a bank's index (the existingBankIndex::removeprimitive, 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 thecommand_id/gaccel/hookcommandcontract; 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) alongsideM(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 thebanksblob) 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 − referencedorphan 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_ALLOWUNDOconfirmed 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_panelbutton 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 thebank_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 thecommand_id/gaccel/hookcommandcontract; plus abank_panelbutton (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, macOSNSFileManager 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 viaSHFileOperationW+FOF_ALLOWUNDO, verified against SDK 10.0.26100. macOS / Linux: no portable SWELL trash surface found — fall back tounlinkbehind 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 thebanksblob — 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/hookcommandcontract frommain.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.banksblob) 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_panelbutton, 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_reasamplerextension 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. NamespacedS(Sampler) rather than "D" (which would collide with Design View). TheM/D/B/R/Vextension 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
.rppvia the M4 machinery. The instrument resolves those paths the same waypersistdoes (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 whatpersistlast 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 toSampleas an additive field extension — the same shape as howprovenancewas 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
SingleComponentEffectskeleton 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 VST3IPlugViewthat 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 dockbank_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
peaksasserts an envelope). This is D3's pure core and the heart of the phase.
Shell (VST3-facing / REAPER-facing, thin):
- VST3 wrapper —
SingleComponentEffectsubclass:initialize(declare an event input bus + an audio output bus, no audio input),setupProcessing,setActive,setState/getState, and the hot-pathprocessthat 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+ WindowsInitDll/ExitDll— verify exact export names at the spike). IPlugViewLICE editor — hosts a LICE-drawn surface in the VST3 view seat (window creation/sizing, host→draw/hit-test event routing). Reuses thebank_panelLICE/SWELL competence and house style.- Bridge/state reader — resolves
GetProjExtState/EnumProjExtStateby 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 waypersistdoes.
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.
Samplefield 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 asprovenance(M1). No existingSamplefield changes; noBankIndexbehavior 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.
Channel mode — mono | stereo (D-E, decided 2026-07-26; PLAN.md S7)
Decided direction: the instrument gets a per-instance channel-mode toggle — 1 (mono) or 2 (stereo) — that negotiates the REAPER audio bus automatically. Captures are often stereo; the current mono downmix is a Tier-0 simplification, not a permanent shape.
- Mono mode keeps today's path. The decode-side downmix stands: a stereo source in mono mode downmixes (the existing policy), a mono source plays as-is. No engine change for mono.
- Stereo mode is an S3-core extension, not a shell hack (honest). The S3 core is
mono-per-sample by design today —
SampleData::framesis one mono stream,Voice::renderFramereturns a single value,VoiceEngine::renderwrites one channel. Stereo mode grows the core a channel dimension: 2-channel decoded PCM, per-voice stereo render (per-channel fractional read + linear interpolation + loop), and a per-channel mix in the engine. Mono stays the degenerate (single-channel) case, so existing mono behavior is unchanged. This is why S7 sequences first after the editor/embed work: it touches the engine Daniel smoke-tests. - Where the toggle lives. Per-instance component state (setState/getState), alongside the selected sample — a performance choice the instrument owns, never written to the bank (D-B: not a file fact). Default preserves current behavior (mono).
- Cross-mode policy (settled). Mono source + stereo mode → dual-mono (same signal
both channels, centered). Stereo source + mono mode → downmix (the existing
decode-side policy). The bank's per-sample channel-count intrinsic (already on
Sample) tells the shell how many channels to decode intoSampleData. - Bus negotiation (the "works with the REAPER bus automatically" requirement). The VST3
implements
setBusArrangementsso the output bus reports mono or stereo per the instance's channel mode, and REAPER's routing follows without manual channel wiring. Must-verify before build: thesetBusArrangements/getBusArrangementcontract and REAPER's mono/stereo instrument-bus expectations against the vendored Steinberg SDK +reaper_vst3_interfaces.h.
Ingest through the bank — the extension owns ingest (decided "option 1", 2026-07-26; PLAN.md S8)
Decided: loading a sample into the sampler is ONE gesture — capture/import-into-bank AND
auto-assign to the active sampler instance — and the extension owns it. The instrument
stays a read-only bank consumer; it never captures and never imports. The extension is
the right owner: it has arrange access, Media-Explorer access, and the drop-target surface
on its own docked panels. Ingest lives in the extension codebase (actions + bank_panel +
capture/import add-path), routing through the existing capture add-path and the live
"reasampler" seam the instrument already reads.
The three ingest surfaces, with the honest SDK reality (verified against the vendored headers):
- Arrange capture → bank → assign. A one-click action captures the selected item /
time-selection into the bank (reusing the existing capture request path —
CountSelectedMediaItems/GetSelectedMediaItem+GetSet_LoopTimeRangeare already the capture inputs) and assigns the resultingSampleid to the target instance. It never inserts a timeline item — the capture/placement separation is load-bearing; assignment is a bank-index + instance-selection act, not a placement. - Media Explorer import → bank → assign. The Media-Explorer surface is thin:
OpenMediaExplorer(open/select a file) andMediaExplorerGetLastPlayedFileInfo(read the one last-played/selected file path + its selection range/pitch/vol/rate) are the whole contract. There is no enumerate-selected-files and no register-a-drop-handler-on- the-Media-Explorer API. So ME import is single-file, pull-on-action — an action fired while a file is selected in the ME — not a push/drop from inside the ME. Spike: confirmMediaExplorerGetLastPlayedFileInforeturns a usable path+range for a merely-selected (not-yet-played) file, or whether a play is required first. - Drag-and-drop onto ReaSampler surfaces. REAPER exposes no drag-drop registration
API. Drop handling is on ReaSampler's own HWNDs via SWELL/Win32 (
WM_DROPFILES/IDropTargeton the dockedbank_panelHWND — the surface the panel already owns) → ingest → assign. Assess-and-flag (spike, not promised): a drop onto the VST3 editor window — whether theIPlugViewHWND can accept an OS file drop and relay it to the extension as a bank-ingest request (the instrument does not ingest; it forwards a request over an agreed seam). This crosses the two-artifact boundary and the relay is unproven; if gnarly, drop-onto-panel is the shipped path and drop-onto-editor is deferred.
The assign seam. The ingest action names the target instance (lean: the active/ last-focused instance, discovered via the host context the bridge already resolves) and hands it the new sample id — the same instance-owned selection state S4 already persists, so a reload picks it up. With the change-detection seam (below) the assignment refreshes hands-free; without it the ingest action pokes the target instance's reload directly.
Guardrail (load-bearing, restated): ingest is an extension act. Any instrument code path that captures, imports, inserts a timeline item, or writes back into the bank is a bug — the instrument reads and plays only.
Bank-generation change-detection — hands-free refresh (decided 2026-07-26; PLAN.md S9)
Instances reference sample ids. So a recapture (M10) landing under the same id — or
an ingest (S8) touching the active bank — should refresh playing instances hands-free,
without re-opening each editor. The missing trigger: a bank-generation counter in
"reasampler" ext-state.
- Writer (extension). A monotonic bank-generation counter, stamped into
"reasampler"ext-state under a new forever-stableext_keys.hconstant, bumped on every bank-content mutation that changes what an instance would play (capture add, recapture-in- place, sample-remove, move/copy affecting the active bank). Additive to the persist blob; defaults to 0 for projects saved before the stamp exists. - Reader (instrument). Poll the generation over the bridge on a safe off-audio-thread
cadence (a UI/timer tick, never
process), compare to the last-seen value, and call the existing off-threadreloadFromBank()on change — reusing S4's atomic pointer-swap handoff (graveyard-reclaim) so a mid-play refresh does not glitch. No new audio-thread work; no allocation inprocess. - Cadence + safety. A low-frequency UI timer, coalescing multiple bumps between polls into one reload (build-time residual). The read already tolerates a stale value by design (it reloads on the next poll). Must-verify before build: no torn-read hazard on the single integer generation key for a bridge read on the instrument's UI/timer thread concurrent with an extension write.
This seam serves both S8 ingest and M10 recapture; the writer side is extension-only and independent of S8, so it can land alongside either.
ReaSampler 9000 — the UX overhaul (S10–S13; DAW-tested S1–S6, "the UX is awful")
The bar is set: better than ReaSamplOMatic5000. Daniel DAW-tested the S1–S6 instrument and the verdict was that it works but the UX is unacceptable — "this is supposed to be better than ReaSamplOMatic5000." The S1–S6 editor was a spike-grade LICE panel: a clickable sample list, zone rows each carrying seven tiny ±1 nudge/delete mini-buttons (low-/low+/high-/high+/root-/root+/delete), text-only labels, no keyboard visualization, no waveform, no drag interaction of any kind, no scrolling for long lists. Setting a zone from C1 to C4 by clicking "+" thirty-six times is the catastrophe; the rest (no way to see a sample, no loop editing by eye, unreachable rows past the panel bottom, a fixed envelope) compound it. The overhaul is scoped as S10–S13, sequenced so the friction Daniel feels every test pass is removed first.
What "better than RS5K" means, specifically (not vibes). RS5K's genuine strengths — match or beat each: (1) drag a file straight onto it loads the sample (our S13 relay); (2) note-start / note-end range with a visual sense of the keyboard (our S10 keyboard strip — RS5K's own range UI is two number fields, so a draggable strip beats it); (3) a waveform with draggable start/end/loop markers (our S11); (4) ADSR sliders (our S12); (5) velocity layers / round-robin (Tier 2 — held, not in this overhaul). RS5K's real weaknesses are our opening: its one-sample-per-instance model forces track sprawl (one RS5K per drum) and it has no multi-zone view in a single instance — ReaSampler 9000 is multi-zone in one instrument by design (S5), so the keyboard-strip editor showing all zones at once is a capability RS5K structurally lacks. "Better than RS5K" = direct-manipulation where RS5K uses number fields, multi-zone where RS5K is one-shot, and bank-integrated ingest where RS5K is file-at-a-time.
Constraints (unchanged — settled, do not re-open): LICE/SWELL drawing only (no toolkit
change — D-A settled); all layout/hit-test math in pure geometry modules (mirror of
mode_switch / editor_geometry / embed_strip), the draw + drag-state machine in the
shell; RT discipline untouched (every edit commits off the audio thread via the
existing commitMapAndReload → off-thread reloadFromBank → atomic swap); the instrument
stays a read-only bank consumer (loop/root/ADSR edits are the instrument's performance
map, D-B — never written back to the bank); component-state persistence and
read-only-over-bank stay settled.
- S10 — direct-manipulation keymap editor. A piano-keyboard strip along the top;
each zone a bar over the keys it covers. Drag an edge → change low/high note; drag
the bar body → move the zone (span preserved); click a key → set/relocate the
zone's root. Retires the seven ±1 nudge buttons per row. New pure
keyboard_stripgeometry module (key-span↔pixel reusing theembed_stripidiom; edge-grab vs. body-move hit regions;pixel→note; drag-delta resolver). Shell extends the click-onlywndProcto aWM_MOUSEMOVE/WM_LBUTTONUPdrag-state machine with live feedback, one coherent edit on release. - S11 — waveform view + draggable loop points. Selecting a zone shows its sample's
waveform (peaks via the existing
peaksmodule over the shell's already-decoded PCM — no new decode/WAV path) with draggable start/end/loop-start/loop-end markers that snap to zero-crossings (the S2 zero-crossing-aware requirement). A dragged loop is a per-zone loop override (additive onPerformanceZone, same shape asrootOverride; seeded from the S2 bank intrinsic, never written back). Marker/waveform geometry pure (frame↔pixel, marker grab regions, clamp start≤end, zero-crossing snap helper). - S12 — scale + ergonomics. The sample list scrolls (wheel + scrollbar) so a long
bank is fully reachable, and has a type-to-filter search. Direct numeric entry
for zone low/high/root (a click-to-type field over the strip, for precision the drag
can't hit). An ADSR editor — four draggable controls over the S3
AdsrParams(the math already exists and is wired into the voice engine; today the envelope is a fixed default). Scroll/search/slider/entry layout pure; ADSR + (implicitly) any exposed parameters become per-instance component state (additive, version-bumped, back-compat). - S13 — drop-to-load (the S8 relay, in the editor). Dropping an OS file / media item
onto the editor window ingests into the bank + assigns to this instance — the RS5K
"drop a file straight on it" affordance. The instrument does not ingest: the editor's
drop handler relays a bank-ingest request to the extension (S8's
option 1), which performs the capture/import + assign; refresh is hands-free via S9 (or a direct reload without it). Cross-artifact relay is the S8-flagged spike — proven-and-shipped or degrade to the docked-bank_paneldrop path with a clear affordance. Never inserts a timeline item (capture/placement separation intact).
Sequencing (recommendation, argued below in this section's tail). S10 first — the nudge-button zone editor is the friction Daniel hits on every test pass, so removing it buys the most felt improvement per unit work and de-risks the drag-state machine S11/S12 reuse. S11 and S12 follow (both lean on S10's drag machine). S13 depends on S8's ingest seam, so it sequences after S8. Against the queued engine work: S10 should land before or interleaved with S7 (stereo). S7 is a real engine capability (stereo capture in true stereo) and touches the DSP Daniel smoke-tests — but the reason he'll keep smoke-testing is the editor, and today every test pass is taxed by the nudge-button UX. Fixing what he feels first (S10) makes every subsequent S7 test less painful; there is no hard dependency either way (S7 is engine/bus, S10 is editor/geometry — orthogonal). Honest counter: if the stereo sound is the thing blocking real use, S7 first is defensible — but "it works, the UX is awful" points at the editor as the live wound, so S10 leads.
Product name — ReaSampler 9000 (Daniel, 2026-07-26)
The MIDI-playback instrument's product name is ReaSampler 9000. The extension stays ReaSampler (capture + organization); the instrument is ReaSampler 9000 (playback). Set by Daniel on DAW-testing the S1–S6 instrument, alongside the UX-overhaul directive.
- Propagate the display name across user-visible surfaces: the VST3 class display
name string in the factory registration, the
IPlugVieweditor title band (currently "ReaSampler Instrument"), the S6 embed-strip label, and the Phase S docs. - Do NOT change the VST3 class UID. Instances in already-saved projects key off the class UID; changing it orphans every existing instance in every saved project. The UID is a forever-stable contract (mirror of the command-id / ext-state-namespace forever-stable strings). The name change is display-string-only on the code side.
- Fork S-NAME-1 (Daniel's call): binary filename. Renaming the built module (e.g.
reasampler_9000.vst3) carries compat weight — REAPER partly keys a saved project's plugin reference by filename, so a rename can force users to re-select the plugin on existing instances. Lean: keep the filename, change only display strings (zero compat weight; the name shows everywhere the user reads it). Flagged for Daniel, not decided.
REAPER / Steinberg API surface (verify all signatures)
- VST3 SDK (a new vendored dependency — vendor it at the spike).
FUnknownand theIComponent/IAudioProcessor/IEditControllerinterface family; theSingleComponentEffect/EditControllerEx1/AudioEffectbase classes; the class factory (GetPluginFactory+ factory macros);IPlugViewfor the editor;ProcessData/ProcessSetupfor 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.
hostcbopcode0xdeadf00d(resolve API function by name) and0xdeadf00e(host context); the by-name resolution ofGetProjExtState/SetProjExtState/EnumProjExtState. Verify againstvendor/reaper-sdk/sdk/reaper_plugin.h+video_processor.h+reaper_plugin_functions.h. - Embedded UI (D-D, later point).
IReaperUIEmbedInterfaceand the embed message/lifecycle contract — verify againstvendor/reaper-sdk/sdk/reaper_plugin_fx_embed.hbefore use. - VST3 bus arrangement (S7 channel mode).
setBusArrangements/getBusArrangementand REAPER's mono/stereo instrument-bus expectations — verify against the vendored Steinberg SDK +reaper_vst3_interfaces.h. - Ingest surfaces (S8).
InsertMediais the placement path (untouched by ingest);CountSelectedMediaItems/GetSelectedMediaItem+GetSet_LoopTimeRangeare the arrange-capture inputs (already the capture path's);OpenMediaExplorer+MediaExplorerGetLastPlayedFileInfoare the whole Media-Explorer contract (thin — no enumerate-selected, no ME-drop-handler). Drop handling is SWELL/Win32 on ReaSampler's own panel HWNDs — REAPER exposes no drag-drop registration API. All verified againstreaper_plugin_functions.h. - Bank-generation seam (S9). New forever-stable
ext_keys.hkey for the generation counter; read over the same bridgeGetProjExtStatepath S4 already uses. No new API — confirm no torn-read hazard on the integer key. - LICE/SWELL editor. Reuses the
bank_panelLICE/SWELL drawing surface; verify theIPlugView↔LICE window/bitmap bridge at the spike (window creation, sizing, event routing) — the least-trodden edge of the phase.
Drop-and-load — drag a capture onto a track's FX button (S17 spec)
The gesture. While a capture is dragged out of the bank_panel, a track's TCP FX
button becomes a drop zone. Dropping the capture there instantiates a ReaSampler 9000
on that track with the dragged capture already loaded and selected for playback — one
gesture from bank to playable instrument. This is the third integration gesture: capture
(extension), placement-into-arrange (extension), and now placement-of-the-player
(this wave). It is drop-and-load, not drop-to-arrange — no media item touches the timeline.
Why it needs a new drag mode (the CF_HDROP path can't carry it). Today's drag-out
(M11) becomes an OS file drag (CF_HDROP via drag_out + drag_out_win) the moment
the pointer leaves the panel client rect. REAPER's TCP FX button is not a native drop
target that instantiates a plugin-with-a-file, so this feature cannot ride the OS-drag
path: an OS drop of a WAV onto the FX area does not create "an instrument preloaded with
that WAV." It requires an internal drag where the extension itself tracks the pointer
over REAPER's own UI, detects the FX-button hover, and on release drives the insert
itself. The extension is the actor for the whole gesture.
The two-part mechanism.
-
Internal-drag hover detection (extension-side, pure + shell). The
drag_outpure module gains a thirdDragGesturebeyondInternal(bank-to-bank) andOsDrag(M11) —InstrumentDrop. The gesture decision is refined: leaving the panel client rect no longer immediately means OS-bound. Instead:- Pointer inside the panel client rect →
Internal(unchanged bank-to-bank drag). - Pointer outside the panel but still over REAPER's own window/UI →
InstrumentDrop(new — the shell hover-tracks the TCP FX button and highlights it). - Pointer left REAPER entirely (Explorer / another app) →
OsDrag(unchanged M11).
The pure module stays REAPER-free: it decides
InstrumentDropvs.OsDragfrom position plus an "over-REAPER's-own-UI" predicate the shell supplies (the shell owns the REAPER window/hit query; the pure layer owns the set/boundary algebra). Mirror of how M11 keptdecideGesturepure over a rect the shell supplied. The shell then resolves the pointer to a track + FX-button hotspot, highlights it, and on release drives the drop. - Pointer inside the panel client rect →
-
FX-button drop → add-VST + load-capture (extension-side shell, then instrument seam). On release over an FX button the shell:
- Adds a fresh instance:
TrackFX_AddByName(track, "VST3:ReaSampler 9000", /*recFX*/ false, /*instantiate*/ <negative>). Verified present inreaper_plugin_functions.h:int TrackFX_AddByName(MediaTrack* track, const char* fxname, bool recFX, int instantiate)— a negativeinstantiatealways creates a new effect (per the header comment); the"VST3:"prefix selects the format. Captures the returned FX index (or-1on failure). - Loads the dragged capture into that instance via the load-capture seam (below).
- Wraps the whole thing in one REAPER undo point (
Undo_BeginBlock2/EndBlock2) so the gesture is one Ctrl-Z — the same discipline the bank verbs use.
- Adds a fresh instance:
The ReaSampler 9000 load-capture seam (the hard coupling — MUST be added; does not yet
exist). The Phase S spec today gives the instrument a live-state read seam (it
reads bank index + mapping from "reasampler" ext-state via the bridge — §The two seams)
but no entry point for an external actor to say "this fresh instance should play this
specific capture." Reading the bank is not the same as being pointed at one sample.
This wave is the reason to add that seam, and the seam lands inside the instrument
(the phase-s artifact), not the extension. Two candidate mechanisms — the choice is a
Phase S open question:
-
(A) Fresh-instance ext-state handshake. The extension writes a small "pending load" hint into
"reasampler"ext-state keyed to the target track/FX (e.g. the capture id + a target GUID); a freshly-instantiated ReaSampler 9000 reads it on init via the same bridge it already uses, claims + clears the hint, and self-selects that capture. Pro: reuses the existing bridge; no new VST3 surface. Con: a cross-process handshake with a claim/clear race to get right; "which instance claims which hint" needs a stable key. -
(B) VST3
setState/ preset injection. The extension builds the instance's component state (the same blobgetState/setStateround-trips) with the capture pre-selected and injects it right afterTrackFX_AddByName. Pro: deterministic, no shared-state race, uses the instrument's own persistence format. Con: the extension must know and construct the instrument's state blob format — a tighter cross-artifact coupling to a format that is itself still being built in Phase S; verify whether the extension can set an added FX's state through the REAPER API (candidate:TrackFX_SetNamedConfigParm/ a state-set path — not yet confirmed againstreaper_plugin_functions.h).Lean: (A) keeps the artifacts loosely coupled through the one ext-state seam they already share and avoids the extension hard-coding the instrument's state format — but it inherits the claim/clear race. Daniel to decide (open question below).
Coexistence with the OS drag-out (disambiguation contract). The two OS-vs-internal modes are disambiguated by pointer location, not a mode toggle — the user never picks "OS drag" vs. "instrument drop"; the extension infers it from where the pointer is when released. The M11 boundary (left the client rect) is refined, not replaced: leaving the rect now asks "over REAPER's UI → InstrumentDrop, else → OsDrag." Both M11 OS drag-out and the internal bank-to-bank drag must remain byte-for-byte unchanged in their own regions — this wave only inserts a new middle case. Multi-capture payloads are a disambiguation input too (see open question — instrument drop is naturally single-capture; a multi-capture drag over the FX button is either rejected or loads the first).
Precision / invariant implications (drop-and-load).
- Explicit user-driven placement — consistent with capture↔placement separation. This is a deliberate placement gesture: the user chooses to put a playing instrument on a track, exactly as inserting an item into the arrange is a deliberate act. It does not auto-capture (the file already exists in the bank) and does not insert a media item into the timeline. It instantiates a reader of the bank on a track and points it at one already-captured sample. Capture, placement, and playback stay three distinct acts; this is placement-of-the-player, not a capture and not a timeline insert.
- No private sample copy. The instantiated instrument consumes the one authoritative bank (it resolves the WAV via the shared M4 project-relative machinery like any ReaSampler 9000 instance); the seam hands it a reference (a capture identity), never a copied file. Any path that copies bytes into the instance is a bug.
- The internal drag stays pure-decidable and testable. The new
InstrumentDropgesture is decided in thedrag_outpure module (REAPER-free) over a shell-supplied predicate; the M11drag_outunit tests must not regress.
Open questions (Daniel / Phase S team to decide).
- Load-capture seam mechanism (A vs. B above). The single load-bearing Phase S design choice this wave forces. Lean is (A) for loose coupling; needs Daniel's call.
- Multi-capture drag over an FX button — reject (only single-capture drags arm
InstrumentDrop), or load the first / a keymap of all? Tier-0 leans reject-or-first; a multi-capture keymap load is a Tier-1 stretch. - FX-button hotspot vs. whole TCP. Does the drop zone have to be the FX button specifically, or is dropping anywhere on the target track's TCP enough (simpler hit resolution, arguably clearer target)? Depends on what the SDK exposes (see must-verify).
- Numbering vs. the
phase-sworktree (reconciled on merge to dev). Authored on dev as a provisional S7; renumbered to S17 on merge, since the worktree's authoritative Phase S set (S7 stereo, S8 ingest, S9 change-detection, S10–S16) took the lower labels. Drop-and-load stays a distinct integration gesture (drop onto a track's FX button) — a sibling of but not the same as S8 (ingest into the bank) and S13 (drop-to-load inside the editor). See PLAN.md §S17.
Must-verify before build (drop-and-load).
TrackFX_AddByName— verified present (reaper_plugin_functions.h): signature and the"VST3:"-prefix + negative-instantiatesemantics confirmed from the header.- Pointer→track / FX-button hit resolution during a drag — not yet confirmed.
Candidates:
GetTrackFromPoint/GetThingFromPoint(verify names + signatures againstreaper_plugin_functions.h); whether the FX button specifically is addressable vs. the TCP as a whole is an open verification that also decides the "hotspot vs. whole TCP" question. - Instance state injection (only if seam mechanism (B) is chosen) — whether the
extension can set a just-added FX's state via the REAPER API
(
TrackFX_SetNamedConfigParmor similar) — not yet confirmed againstreaper_plugin_functions.h. Moot if (A) is chosen.
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).
- The instrument never ingests (S8). Capture, import, and drop-ingest are extension acts; the instrument only reads and plays. A drop onto the editor window (if the spike proves it viable) is relayed to the extension as an ingest request — the instrument never writes the bank itself.
- Channel mode is a performance choice, not a bank fact (S7). The mono/stereo toggle is
per-instance component state, never written to
Sampleor the bank (D-B). The bank's per-sample channel-count intrinsic is a file fact; the play mode is the instrument's. - 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
Sampleintrinsic-field addition is additive (new optional fields; no existing field orBankIndexbehavior 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. Note: S7 stereo is not a Tier-2 feature — it is a channel-count dimension on the existing Tier 0–1 engine, orthogonal to Tier 2's velocity-layers / round-robin / per-sample trim. (S7's stereo loop read is the same loop the core already has, extended per-channel — not the Tier-2 "sustain loops" feature.)
- Drop-and-load must not regress the two existing drags. S17 inserts a new middle case
(
InstrumentDrop) between the M11 OS drag-out and the internal bank-to-bank drag; both existing gestures stay byte-for-byte unchanged in their own regions. Drop-and-load never inserts a media item into the arrange and never copies sample bytes into the instance — it hands the new instance a reference to an already-captured bank sample. - 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_FillRectblocks + raw GDIDrawTextA) 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). NamespacedL(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 againstvendor/WDLbefore use. When a point lands, doc-keeper moves it toCOMPLETED.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 viaLICE_RoundRect/LICE_Line, cached AA text viaLICE_CachedFont). Reuse any useful WDL/vwnd piece — skin/image helpers, draw idioms, a specific control (e.g.virtwnd-listboxfor 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/hotis 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/dimon 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 onbg/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/primarylime,accent/secondaryteal,accent/tertiarypurple) 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 onbg/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 sharedtext()over a cachedLICE_CachedFontset (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
microsub-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: ArrangeandTrack: Arrangeare live (they send the selection to Arrange);Item: DesignandTrack: Designare disabled (the selection is already there). - Arrange active → the reverse: the
…: Designbuttons are live; the…: Arrangebuttons 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 loneShow 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). TheTrackPopupMenupopup, theMain_OnCommanddispatch, 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 L4bank_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. Trailing empty tail is trimmed for scroll-extent purposes; interior gaps are preserved (confirmed at build).
- 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. Drop-into-empty-slot places there; drop-onto-occupied inserts-before and shifts the tail (matching common file-manager reorder) — SETTLED as the default drop. (The Alt+drop-onto-occupied = REPLACE override is specified below under Drag disambiguation, F3.)
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.
Position-model shape (F2 — SETTLED 2026-07-27: interchangeable substrate, NOT fixed slots). The carrier is the plain gap-preserving interchangeable-slot substrate — a per-bank id→slot map (Daniel: "I don't think I want fixed slots MPC style, but the substrate of interchangeable slots is valuable"). It is not M9-shaped: no slot identities, no numbered/addressable slots that persist independent of their occupant, no slot actions, no MIDI-bindable slot numbers, no capture-to-slot-N. A slot is just a display position a sample occupies; dragging cards rearranges which sample sits where.
M9 overlap (awareness note — unchanged intent). M9 "slots" (capture-to-slot-N / insert-slot-N, MIDI-bindable, MPC-style) remains explicitly deferred (Daniel, 2026-07-26). The interchangeable substrate L7 builds still eases a future M9 revival — it lays the addressable-position groundwork M9 would sit on — but L7 adds no slot-numbered capture/insert actions and no MIDI bindings. The "plain vs. M9-shaped" sub-fork is closed: plain gap-preserving substrate, per F2 above.
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 (F1 — SETTLED 2026-07-27: capture-time stamp). bars.beats.subdivisions
requires a tempo + time-signature reference. Sample already carries captureTempo (BPM at
capture) and lengthBeats, but no time-signature. Decision: capture-time stamp — add
captureTimeSigNum / captureTimeSigDenom to Sample + its JSON round-trip, stamped on the
capture path (read the project meter at capture via TimeMap_GetTimeSigAtTime — confirmed
at build against the SDK). bars.beats.subdivisions renders
from the stamped tempo + meter, stable under later project tempo/meter changes — a bank sample
outlives the project state it was captured under, matching the existing captureTempo stamp
philosophy. This is a capture-path write beyond draw work (its own checkbox in the PLAN, now
settled). 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/primaryinner 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/hotoutline on the target slot) must not be confusable with the purple selection border; spec the exact treatment at build.
Drag disambiguation (F3 — SETTLED 2026-07-27)
In-grid reorder must coexist with the existing internal bank-move/copy drag and OS drag-out. Precedence (one clean rule, evaluated live during the drag):
- Pointer leaves the client rect → OS drag-out (unchanged;
drag_out::decideGesturewins first — the existing invariant #4 boundary). - Else drop lands on a tab / the OTHER region's bank → move/copy (unchanged; Ctrl = copy).
- Else drop lands within the SAME bank's own grid → reorder-to-slot (new).
- Onto an empty slot → place there.
- Onto an occupied slot, no modifier → insert-before-and-shift-tail (the default, above).
- Onto an occupied slot, Alt held → REPLACE the occupant (below).
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 + modifier state (Alt) 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).
Alt+drop-onto-occupied = REPLACE (SETTLED 2026-07-27). Holding Alt at drop over an occupied slot replaces the occupant instead of inserting-and-shifting. Replace semantics, precisely:
- The replaced sample is removed from THAT bank's index only — same semantics as the existing
remove-from-bank verb (
BankBook::removeSampleindex-only). The file stays on disk; the owned-manifest and Phase R prune govern its bytes. If the replaced sample's last reference disappears, that is exactly the existing cross-bank-reference story (hashReferencedElsewherereports whether the hash is still referenced elsewhere; prune later reclaims a now-orphaned owned file). Replace introduces no new deletion authority — it never touches the disk. - The dragged sample then takes the vacated slot (the slot's position is preserved; only its occupant changes).
- Pool case (un-evacuable pool — SETTLED rule). The pool's privileges (un-deletable,
un-renamable, un-evacuable, never zero banks) are enforced in
bank_book. Alt+Replace is allowed in the pool only when it does not violate a pool privilege. Concretely: replacing a pool entry is an index-only removal of that pool sample; it is permitted as long as it does not empty the pool below the pool's floor and does not remove the last reference in a way the pool's rules forbid. The consistent rule the model enforces: the replace's index-removal step is the same operation as remove-from-bank, and it must pass the same pool-privilege guard that remove already applies — if remove-from-pool would be rejected for that sample, Alt+Replace over it is rejected too (the drop falls back to a no-op; the pool-privilege guard is reused as-is — confirmed at build). No special pool-only replace path; one rule, guarded by the existing pool invariants.
Drop-result cursor cues (SETTLED 2026-07-27 — REAPER-idiomatic special cursors). During a drag the cursor must indicate what the drop will do, following REAPER's idiomatic use of distinct action cursors. The cue set:
- Reorder-to-slot (within the same bank's grid) — a move/reorder cursor.
- Move/copy to another bank or tab — the move (or copy, when Ctrl is held) cursor, matching the existing internal-drag semantics.
- OS drag-out (pointer left the client rect) — the OS copy/drag cursor (owned by the OS drag loop once handed off).
- Replace — a distinct replace cursor, shown only when Alt is actually held over an occupied slot (i.e. only when precedence resolves to the Alt+replace case). It must not appear over an empty slot or when Alt is not held.
Shell mechanism: the shell sets the cursor via Win32/SWELL SetCursor. SWELL stock cursors
were chosen at build (Reorder→IDC_SIZEALL, Move→IDC_HAND, Copy→IDC_UPARROW,
Replace→IDC_SIZEWE; no custom cursor load/synthesis required). The decision of which cue
applies stays in the pure gesture/disambiguation helper — the same helper that resolves
precedence returns the resolved gesture (reorder / move / copy / os-drag-out / replace), and the
shell maps that pure result to a cursor. No cue logic in the shell; the shell only owns the
SetCursor call and the cursor resources.
Pure/shell discipline (L7)
Model: the position carrier + gap semantics + reorder mutator + Alt-replace mutator (the
latter reusing the existing index-only remove-from-bank semantics + pool-privilege guard) + 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 (including the resolved-gesture result that drives the cursor cue,
and the Alt-over-occupied → replace resolution) 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/replace drag wiring + drop-target highlight, the
cursor SetCursor call mapping the pure resolved-gesture to a cursor resource, the
capture-time-signature stamp (F1, settled) 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, L5, L6, and L7 have all landed — see COMPLETED.md.
L4, L5, L6, and L7 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
processor 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, AALICE_Line/LICE_FLine/LICE_ThickFLine/LICE_Circle/LICE_FillCircle/LICE_DrawCBezier,LICE_FillTriangle/FillTrapezoid/FillConvexPolygon, and theLICE_CachedFont/LICE_IFontfont engine (SetFromHFont, AADrawText, shadow/outline/ glow FX flags). Verified present invendor/WDL/WDL/lice/lice.h+lice_text.h; confirm exact signatures + theLICE_CachedFont↔HFONTlifecycle 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 invendor/WDL/WDL/wingui/. Reuse where a piece beats re-deriving; keep hit-test geometry pure regardless. - Panel drawing/hit-test (L2). Reuses the
bank_panelLICE surface + the existing purebank_grid/tab_strip/mode_switchhit-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-drivenwndProc. Verify against the SWELL headers asbank_panelalready 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/WDLbefore 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. NamespacedQ(Quality) — M/D/B/R/V/S/L taken;Qnames 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 toCOMPLETED.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 familyQ1..Qn, wave prefixesQ-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
Parseris 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).
- Q-7 — naming rides the relocation waves, no dedicated naming wave. REC: yes (forced once Q-3/Q-4 settle — a rename is near-free during relocation, near-pure-churn standalone).
- Q-8 — class/module renames beyond the free namespace fix. REC: fix the two that actively
mislead —
BankIndex→BankModel(thebank_model.h/BankIndexfile↔class word-mismatch) and the unified JSON parser →json::Reader/json::Writer(orjson::Parser) — leave the merely-quirky (Book/Bank/Index,Sample/AudioSample,MinMax,KitBox). Daniel's to call. - Q-9 — align the
capture_realtime(shell) /realtime_record(pure) word-order inversion. REC: yes, during Q-W3 (a free rider — W3 already hoists the realtime lifecycle).
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,provenancecore/view/(::view) —view_mode_model,view_tree,lane_keys,mode_switchcore/capture/(::capture) —render_settings,batch_capture,tail_control,capture_paths,wav_trim,insert_plancore/audio/(::audio) —peakscore/ui/(::ui) —theme,component_geometry,bank_grid,tab_strip,action_buttons,prune_buttoncore/reclaim/(::reclaim) —prune_reconcilecore/version/(::version) —app_versioncore/json/(::json) — NEW — extracted parser/serializer (replaces the 4 duplicateParsers)
shell/ (REAPER-facing — subdir by subsystem, namespace as house style prefers):
shell/capture/—capture,capture_realtime,provenance_shell,track_guid,item_read; post-Q-W3capture_orchestrator,scope_resolve,realtime_lifecycleshell/panel/—draw_kit; post-Q-W2panel_render,panel_thumbnails,panel_audition,panel_input,panel_bank_ops,panel_window(frombank_panel)shell/view/—viewshell/persist/— post-Q-W5session,ext_state_io,prune_fs(frompersist)shell/actions/—drag_out_win; post-Q-W4design_view_actions,bank_actions,prune_action(fromactions)
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 fatbank_panel.hper seam (I).main.cpp(1762 LOC → hoist toshell/capture/, Q-W3):capture_orchestrator(RunCapture/captureAndIndexOne/renderOffline/batch/recapture/realtimeRun*) /scope_resolve(range/razor/track resolution + provenance assembly inputs) /realtime_lifecycle(state machine + globals + selection guards).FxBypassGuardmoves 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). DedupepromptText/mintBankId+ bank verbs againstpanel_bank_ops.persist.cpp(766 LOC →shell/persist/, Q-W5):session(lifecycle/poll +BeginLoadProjectStatereload hook) /ext_state_io(serialization bridge + GUID minting + folder relocation) /prune_fs(prune scan +deleteOrphanFileviaSHFileOperationW— 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 Parsers.
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).
The naming dimension (Q-7 / Q-8 / Q-9 — grep-verified audit in docs/product/code-organization.md §2b)
Alongside giving symbols a directory + namespace home (Q-3/Q-4), Phase Q gives inconsistently/poorly-named symbols a consistent name — same Vital bar, orthogonal to the SOLID focus. The audit (2026-07-27) is grep-verified; the load-bearing findings:
- Already consistent — preserve verbatim: the geometry-mirror verb vocabulary
(
compute<Thing>Rects/compute<Thing>+hitTest<Thing>, verified acrossbank_grid/mode_switch/action_buttons/action_bar/tab_strip/prune_button/overflow_menu/footer_bar/card_drag/component_geometry) and the uniform<module>_testsCTest suffix. - Collisions — resolved by the Q-4 sub-namespaces for free: four
class Parser(bank_model.cpp/bank_book.cpp/owned_manifest.cpp/view_mode_model.cpp) collapse to onejson::Parserin Q-W1; the shared pure-UI rect typesFooterRect/ButtonRect(defined inprune_button.h, reused byfooter_bar.hunder an explicit hand-collision "NAME NOTE") get oneui::owner;Sample(model::) vsAudioSample(audio::) de-collide by home. - Genuine renames (Q-8/Q-9 — Daniel's call):
BankIndex→BankModel(thebank_model.hfile↔class word-mismatch — the worst legibility wart, rec: rename the class so the model family readsBankModel/BankBook/ViewModeModel); the unified JSON parser namedjson::Reader/json::Writerat W1 mint; aligncapture_realtime(shell)/realtime_record(pure) to the house shell↔core convention (drag_out↔drag_out_winis the model) during Q-W3. - Sequencing (Q-7): renames ride the wave that already relocates/splits the file — no
dedicated naming wave. Rule: no rename lands on a file the wave isn't otherwise touching. W1
carries the collision + model-class renames; W2 the
panel_*names; W3 the realtime word-order fix. Zero-behavior-change like the rest of Phase Q; verified by the module's own test executable. - Out of scope (never renamed): the FOREVER-STABLE contract strings are not C++ symbols —
command_idstrings, action display names, ext-state namespace ("reasampler"/"reasampler_beta") and keys, thereasampler:lane prefix, the VST3 class UID. Renaming a C++ class is orthogonal to these literals (audit §2b.5).
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:
peaksenvelope compute (computeEnvelope/lastFrameAboveThresholdover full PCM): NO virtual dispatch, NOpeaksinterface, NO added header→TU indirection.computeEnvelopestays a free function onconst std::vector<float>&so it inlines as today. Relocate + namespace only; never wrap in an abstraction.- Audition / preview:
panel_auditionmay 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).
FxBypassGuardruns per-capture, not per-frame — keep it stack RAII when it moves out ofmain.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. Daniel's plain readiness target:
"when Phase S and L3 are finished." As of 2026-07-27 the outstanding work is precisely
(1) Phase S merged to dev (the large second-artifact branch, currently on the phase-s worktree)
and (2) Phase L L3 merged to dev (the VST restyle, itself gated on Phase S). L1/L2/L4/L5/L6/L7
have already landed (see COMPLETED.md) — the earlier "L2 + L3" wording was stale and is
corrected to L3 only. D2 is functionally complete (D2-W1..W3-B landed; its lone open item,
a per-track lane-split panel indicator, is explicitly deferred, not a blocking residual). M9
(slots) is explicitly deferred (Daniel, 2026-07-26), not scheduled work. D2 and M9 are named here
only so that reactivating either re-arms the quiescence condition; neither blocks the gate today.
Why: Phase Q touches nearly every file in src/ (relocate, re-namespace, split the four
largest TUs, plus the §2b renames); every large in-flight branch (Phase S on its worktree, L3 once
it lands) 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.
M9 disposition (Daniel-decision note). "Deferred indefinitely" ≠ "abandoned." Immaterial to the gate (both clear it); matters only if M9 is reactivated — before Phase Q it lands cheaply on the flat layout, after it is authored against the reorganized tree. Surfaced, not silently resolved (full note:
docs/product/code-organization.md§4).
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/jsonextract (delete 4Parsers) + impose the directory/ namespace layout on the 30 clean pure libs + clean shells (pure relocation, no logic change). All later waves assume this layout. Carries the naming collision fixes + the model-class renames (Q-8), which are free during this relocation. - 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_opsdedupe target); Q-W5 best after Q-W4 (prune_action→prune_fsrouting); otherwise parallel-safe. Q-W2 carries thepanel_*names; Q-W3 carries thecapture_realtime/realtime_recordword-order fix (Q-9). - Q-W6 — OCP registration-table + residual fat-header (I) splits. Depends on Q-W3 (registration code isolated first). Sequenced last; most droppable if narrowing.
- Naming (Q-7): no dedicated wave — every rename rides the wave already relocating/splitting its file; a rename that would touch an otherwise-untouched file is deferred.
Precision / invariant implications (what Phase Q does NOT change)
- The pure/shell split is strengthened, never dissolved. Top-level
core/vsshell/makes it structural; no file crosses the boundary; nocore/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.
FxBypassGuardmoves but stays stack RAII with identical behavior. - Capture ≠ placement. No hoisted
Run*/capture_orchestratorpath may gain anInsertMediacall 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_paneldepending 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
peaksdata-ownership change.peaksforcing a whole-filestd::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.