# COMPLETED.md — ReaSampler landed milestones Completed milestone entries removed from `PLAN.md`. Each entry preserves its original Goal, Verify, and checklist points with boxes marked done. --- ## Milestone 0 — Transition scaffold: reaper_mpeview → ReaSampler **Goal:** Retire the MPE scaffold and stand up the sampler's pure core in its place, preserving the pure-core / REAPER-shell split. **Verify:** `cmake -B build -S .` configures clean; `cmake --build build` builds the renamed extension target and the pure-core test target; `ctest --test-dir build` is green with the new `bank_model` + `peaks` suites present. - [x] Delete `src/mpe_model.{h,cpp}` and `src/mpe_view.{h,cpp}`; remove `tests/test_mpe_model.cpp`. - [x] Rename the CMake `project()` and the extension MODULE target from `reaper_mpeview` to `reaper_reasampler` (binary `OUTPUT_NAME` likewise); update `PREFIX ""` / platform SUFFIX blocks to the new target name. - [x] Replace the pure `mpe_model` static lib + `mpe_model_tests` executable with `bank_model` (pure static lib) + `bank_model_tests`; keep the CTest wiring. - [x] Repoint `src/main.cpp`: drop the `mpe_view.h` include and all `MpeView_*` calls (toggle / IsOpen / OnTimer / Cleanup); stub the extension entry so it loads, logs to console, and registers nothing MPE-specific. The `command_id` / `gaccel` / `hookcommand` registration *pattern* is preserved for reuse (CLAUDE.md §REAPER extension contract) — the MPE action string is removed. - [x] Choose and record the persistent action-id prefix for the sampler family (replaces `CEREBELLUM_MPEVIEW_TOGGLE`); this string is forever-stable once shipped (CLAUDE.md §action registration). - [x] Refresh `README.md` layout/next-step sections to the sampler module set. (Landed-work reflection is doc-keeper's; this point exists so the stale MPE README does not mislead the first implementer.) --- ## Milestone 1 — bank_model + JSON round-trip (pure) **Goal:** The `Sample` metadata struct and `BankIndex` (add / remove / query / tier moves / dedup-by-hash) with JSON serialize/deserialize to `std::string`. CONTEXT.md §Data model, §Module architecture. **Verify:** CTest green. Round-trip is lossless (deserialize(serialize(x)) == x) across all fields; dedup-by-hash and tier filtering asserted; **relative paths only** invariant enforced at the model boundary (no absolute path accepted/stored). - [x] Define `Sample` with the full field set (id, display name, relative path, source mode, source range in project time + PPQ, track GUID(s), wet/dry, channels, SR, length sec + beats, capture tempo, optional key, peak/RMS/LUFS, clip flag, tier, content hash, provenance, created ts). CONTEXT.md §Data model. - [x] `BankIndex`: ordered collection keyed by id; add / remove / query. - [x] Hash lookup for dedup-by-content-hash. - [x] Tier model (scratch | archive) + tier-move + tier filtering; scratch marked auto-prunable. - [x] JSON serialize/deserialize to/from `std::string`. - [x] Tests: full-field round-trip lossless; dedup collapses equal-hash adds; tier filter/move correct; relative-path invariant rejects absolute paths; empty-index and malformed-JSON edge cases. --- ## Milestone 2 — peaks (pure) **Goal:** Compute waveform min/max bins from raw PCM, dependency-free (not REAPER's peak API). CONTEXT.md §Module architecture, §Non-goals. **Verify:** CTest green. Fed a known signal (full-scale sine, ramp), asserted min/max envelope per bin matches expected within tolerance; channel count preserved; bin count honored for arbitrary sample lengths (incl. remainder bin). - [x] Min/max bin computation from interleaved PCM given a target bin count. - [x] Multi-channel handling (per-channel envelope; no silent fold). - [x] Tests: sine envelope ≈ ±amplitude; ramp envelope monotonic; DC/silence → zero envelope; short-buffer and non-divisible-length edge cases. --- ## Milestone 3 — Offline capture spike (REAPER shell) **Goal:** Offline-render the time-selection master mix to a wav in the project bank folder, add a `Sample`, log it. The render-driving spike. CONTEXT.md §REAPER API surface (offline render), Build order 3. **Verify (in DAW):** Render runs via `Main_OnCommand(42230)` ("Render using most recent settings") — REAPER always shows its offline-render progress window; no stock/header-documented fully-headless path exists. File lands in the project-relative bank folder at **32-bit float WAV** at project rate (lossless, dither-free → enables bit-identical/null-test). A `Sample` is added to the in-memory `BankIndex`. Non-destructive. Unsaved-project state triggers a Save-As prompt; capture is refused if the user cancels (no default-location fallback). **Bit-identical repeats:** two identical requests produce byte-identical files. **Exact bounds:** rendered length matches the requested range (no rounding, no added silence without an explicit tail). - [x] `ICaptureBackend` interface + `CaptureRequest` (source mode, time range, wet/dry, tail, SR/bit-depth/channels, output path). CONTEXT.md §capture. - [x] `OfflineRenderBackend`: drive `GetSetProjectInfo` render settings + `GetSetProjectInfo_String` file/pattern/format; **verify every flag against `vendor/reaper-sdk/sdk/reaper_plugin_functions.h`.** - [x] Resolve the no-dialog render command/flag on the current REAPER build (open question) and confirm it runs headless. - [x] Populate a `Sample` from the finished file; hand to `bank_model`; console-log. - [x] Verify bit-identical repeats and exact-bounds by hand on a known range. --- ## Milestone 4 — persist (index ↔ project ext state) **Goal:** Write the `BankIndex` JSON to project ext state, reload on project open; project-relative path resolution. CONTEXT.md §persist, §Persistence & paths. **Verify (in DAW):** Index survives Save / Save As / close+reopen; **bank travels with the .rpp**; **relative paths only** in the persisted index (Save As to a new folder still resolves the bank). - [x] `SetProjExtState` / `GetProjExtState` under namespace `"reasampler"`. - [x] Bank-folder resolution from the current project path (`EnumProjects` / `GetProjectPathEx`); store under a project-relative subfolder. - [x] Reload-on-open; confirm survival across Save / Save As. **Notes/decisions:** - Storage: `SetProjExtState` / `GetProjExtState`, namespace `"reasampler"`, keys `bank_index` (serialized JSON) and `project_guid`; relative paths only in the persisted index. - Project identity: keyed off a **minted GUID** stored in ext state (REAPER exposes no native per-project GUID), not the raw `ReaProject*` — a recycled pointer cannot misread a project switch as a Save-As. - Save-As: **copy** semantics (Daniel's decision) — the `reasampler_bank/` folder is copied under the new `.rpp`; the old project's bank stays intact. Every ext-state write calls `MarkProjectDirty` so captures/GUID changes flush on the normal save. - Save-As collision — fixed (DAW-verified): project identity is **GUID-primary** — the stored per-project GUID is the identity of record; a different stored GUID always means a different project (Load its bank), immune to REAPER recycling `ReaProject*` addresses across close/open. The `ReaProject*` pointer is a secondary signal that disambiguates the same-GUID case only: a different object with the same GUID = a Save-As fork (Load + re-GUID to diverge); the same object with the same GUID + a new path = a genuine Save-As in progress (relocate bank). This replaced two earlier iterations: GUID-only (mis-detected forks sharing a copied GUID) and pointer-primary (mis-detected reopen/new-project because it ignored the GUID on address recycling). Non-destructive preserved. --- ## Milestone 5 — bank_panel (docked grid) **Goal:** Docked LICE-drawn grid: thumbnails (from `peaks`), audition, multi-select, keyboard navigation. Reuses the docking setup from the retired `mpe_view.cpp`. CONTEXT.md §bank_panel. **Verify (in DAW):** Grid docks; thumbnails render from computed peaks; audition plays selected sample; multi-select + keyboard nav work. - [x] Docked window + LICE grid render loop. - [x] Thumbnail draw from `peaks` bins. - [x] Audition (play selected sample) + stop. - [x] Multi-select + keyboard navigation. **Notes/decisions:** - Thumbnail cache: in-memory recompute keyed by `(sampleId, drawWidth, bankGeneration)`; peak bins are NOT persisted alongside the index. Cache is discarded on bank change and rebuilt on next draw. (Closes the PLAN "thumbnail cache" open question.) - Audition: stock `PlayPreview` / `StopPreview` API, read-only — display + select + audition only, never inserts into the arrange. Single stop-funnel ensures a leak-free preview lifecycle. Flagged undocumented assumption: `StopPreview` detaches the source before returning; mitigated by the single-funnel design. Escalation path if a runtime pop appears: switch to `StartPreviewFade` + deferred free. --- ## Milestone 6 — insert (placement) **Goal:** "Insert selected sample at edit cursor" via `InsertMedia`. CONTEXT.md §insert, Build order 6. **Verify (in DAW):** Selected sample inserts at the edit cursor wrapped in `Undo_BeginBlock2` / `Undo_EndBlock2`; conform-to-tempo is an explicit flag — **no silent time-stretch** when off. - [x] `InsertMedia(path, mode)` at edit cursor (verify mode bits against SDK). - [x] Conform-to-project-tempo vs literal as an explicit flag (never silent). - [x] Undo-block wrapping. **Notes/decisions:** - Placement target: inserts the focused bank sample onto the **currently selected track(s) at the edit cursor** (Daniel's directive — not a new track). Uses `InsertMedia` base mode 0; for multiple selected tracks the sample is placed on each at the same cursor position, then the original track selection and edit-cursor position are restored — non-destructive to editing state. The entire operation is one undo block. - No silent time-stretch: the `&4` stretch-to-time-selection bit is never set; a pure `insert_plan` test asserts this across all mode combinations. Conform-to-project-tempo is an explicit separate action (`&8`), never on the default path. - Non-destructive to the bank: insert only adds arrange items — no bank/file/ext-state writes. --- ## D1 — view_mode_model (pure) **Goal:** REAPER-free mode registry + membership index + folder-tree-aware visibility derivation + parking/restore planner + JSON round-trip. The heart of the phase; mirror of `bank_model`. CONTEXT.md §Design View (Module architecture — pure). **Verify:** CTest green. N-mode model (not a boolean); Arrange + Design seeded. Restore-planner round-trip (snapshot → park → restore) returns every driven flag to its captured value. Parent-derivation correct against a supplied folder tree. JSON round-trip lossless across modes + membership + show-both + snapshots + active mode. - [x] Mode registry: ordered (id, display name, ordinal); Arrange + Design seeded; add/query more modes (prove N-mode, not binary). - [x] Membership index: `GUID → { mode ids }` + per-track show-both flag; add / remove / retag / query; untagged = Arrange. - [x] Folder-tree-aware visibility derivation: given a supplied parent↔child tree + active mode, compute the visible set (active leaves, derived-visible parents, show-both leaves, master always in). - [x] Parking/restore planner: emit exact (track, flag, value) op-lists for park and restore from active mode + snapshot record. - [x] JSON round-trip: modes + membership + show-both + snapshots + active mode. - [x] Tests: N-mode add/query; parent follows tagged leaf (multi-mode parent); restore-round-trip returns snapshot values (never hardcoded "on"); show-both leaf never parked; unknown/stale GUID tolerated; JSON lossless. --- ## D2 — view shell (apply flags in the DAW) **Goal:** Read the folder tree and drive REAPER flags per the planner. CONTEXT.md §Design View (view shell, REAPER API surface). **Verify (in DAW):** Toggling active mode hides + parks inactive leaves (`B_SHOWINTCP`/`B_SHOWINMIXER`/`B_MAINSEND`/`I_FXEN` + per-FX offline) and restores active ones from snapshot. **Master untouched. `B_MUTE`/`I_SOLO` untouched.** Untagged tracks untouched. Parents follow their tagged descendants. - [x] Build parent↔child tree from `I_FOLDERDEPTH`; feed to `view_mode_model`. - [x] Snapshot prior flag values (`GetMediaTrackInfo_Value`) before parking. - [x] Apply park/restore ops (`SetMediaTrackInfo_Value` for the four flags; `TrackFX_GetCount` + per-FX `TrackFX_SetOffline`). Verify flag names/signatures. - [x] GUID resolution: `GetTrackGUID` / `guidToString` / `stringToGuid` (never index). - [x] Review gate: no path touches master visibility or `B_MUTE`/`I_SOLO`, or any untagged track's owned flags. --- ## D3 — persist slice (view state ↔ project ext state) **Goal:** Serialize the view section into the `"reasampler"` namespace alongside the bank; reapply the active mode on project open. CONTEXT.md §Design View (persist). **Verify (in DAW):** Membership + active mode + snapshots survive Save / Save As / close+reopen; on open, the active mode's visibility + processing is reapplied. Saved-while-parked project restores parked tracks from persisted snapshots (not to a guessed "on"). - [x] Serialize/deserialize the view section under `"reasampler"` (shared blob, distinct section from the bank index). - [x] Reapply active mode on project open (rebuild tree, run the planner). - [x] Confirm survival across Save / Save As; snapshot durability across save-while-parked. --- ## D4 — actions **Goal:** Bindable action set for the mode workflow. CONTEXT.md §Design View (actions). **Verify (in DAW):** Each action registered (bindable in Actions list); toggle + mode-jumps MIDI-bindable; tag/untag acts on the current track selection. - [x] Toggle active mode (cycle; extensible to cycle-all for >2 modes). - [x] Activate mode: Arrange / Activate mode: Design (direct jumps). - [x] Tag selected tracks → Design / → Arrange; Untag selected (= → Arrange). - [x] Show-both for selected tracks (toggle). - [x] Register each (`command_id`/`gaccel`/`hookcommand`); toggle + jumps MIDI-bindable. **Notes/decisions:** - New `src/actions.{h,cpp}` — the Design View action family registered via the `command_id`/`gaccel`/`hookcommand` contract in `main.cpp`; MIDI-bindable. - New `src/track_guid.{h,cpp}` — shared `MediaTrack*` → canonical GUID-string formatter; used by both the view shell and the actions layer (single source of truth for membership keys). - Wiring: actions drive the D2 view shell and D3-persisted model; saved active mode is reapplied on project load via a load-signal seam in `persist` (`loadFromProject` raises it; `main.cpp`'s timer drains it) — `persist` stays model-only. - A pure `nextModeId` free function added to `view_mode_model` (the N-mode cycle decision behind "toggle"), unit-tested in the existing `view_mode_model_tests`. --- ## D5 — in-window toggle affordance (UI) **Goal:** The segmented mode switch in the ReaSampler / bank_panel window header. CONTEXT.md §Design View (UI). **Verify (in DAW):** Segmented control shows current mode (lit segment), one click flips modes via the D4 toggle action, per-mode membership count visible, offlined-FX caveat surfaced as a tooltip. - [x] Segmented mode switch `[ Arrange | Design ]` in the window header; active lit. - [x] Wire the switch to the toggle/activate actions from D4. - [x] Per-mode membership count display. - [x] Offlined-FX re-init caveat as a tooltip on the switch. **Notes/decisions:** - New PURE module `src/mode_switch.{h,cpp}` — REAPER-free layout math for the Design View mode switch: divides a header rectangle into N equal segments (one per registered mode) and hit-tests a point to a segment. Unit-tested via a new `mode_switch_tests` CTest target. Mirror of `bank_grid`. - `src/bank_panel.cpp` — segmented `[ Arrange | Design ]` control drawn in the panel header (one lit segment per registered mode, click activates that mode via `view::applyMode`), with the grid offset below the header. --- ## D2-W1 — view_mode_model lane extension (pure) **Goal:** Extend D1's pure planner to item level for the two-canvas sub-phase: lane↔mode mapping, a managed-vs-manual lane-ownership index, managed-only item-lane ops in the toggle planner, the "which lanes may this toggle touch" query, the auto-tag decision (manual-lane items exempt; pre-existing ⇒ Arrange), and JSON round-trip of the lane index. REAPER-free, unit-tested; mirror of D1. CONTEXT.md §Two-canvas sub-phase (Module architecture — pure). **Verify:** CTest green; D1 behavior and tests unchanged. - [x] Lane↔mode mapping: which lane maps to which mode, which `C_LANEPLAYS` value per mode. - [x] Lane-ownership index: per (track GUID, lane) managed-which-mode vs manual; managed-only item-lane op family alongside the existing track-flag op family. - [x] "Which lanes may this toggle touch" query (managed only) — planner emits lane ops for managed lanes only, never for manual lanes. - [x] Auto-tag decision (pure): new track/item GUIDs + active mode ⇒ membership writes; manual-lane items exempt; pre-existing ⇒ Arrange. - [x] JSON round-trip of the lane-ownership index. - [x] Tests: managed/manual partition; toggle-touches-managed-only; auto-tag exemption for manual-lane items; JSON lossless; D1 behavior/tests unchanged. --- ## D2-W2 — shell: lane application + new-content detection **Goal:** The view shell applies the planner's managed-lane ops in the DAW and the bank_panel timer detects new content and auto-tags it to the active mode. Resolves the two flagged implementation design points (I_FIXEDLANE reorder/renumber fragility; the auto-tag / manual-lane detection heuristic). See CONTEXT.md §Two-canvas sub-phase (Module architecture — shell; New-content detection). **Verify (in DAW):** Toggling a mode shows + plays only the active mode's managed lane, hides + silences the inactive-mode lane, and **never touches a manual lane** (its `C_LANEPLAYS` stays exactly as the user set it); new content created while a mode is active is tagged to that mode; pre-existing content stays Arrange (no mass-tag on the first poll after open). **Depends on:** D2-W1. - [x] Apply managed-lane ops in the view shell (`I_FREEMODE`/`I_FIXEDLANE`/ `C_LANEPLAYS`/`B_FIXEDLANE_HIDDEN` via the item/track info setters; `UpdateTimeline()` after `I_FREEMODE`); **managed lanes only, never manual**. Verify every flag name/signature against the SDK header. - [x] New-content detection on the bank_panel timer: diff the live track/item GUID set against the previous poll; tag any GUID new since the last poll to the then-active mode, with a **first-poll-after-open guard** (pre-existing ⇒ Arrange, no mass-tag) and the **manual-lane exemption** (items in a manual lane not tagged). - [x] Resolve the manual-lane detection heuristic (which new items are exempt) and the `I_FIXEDLANE` lane-identity fragility (index survival across lane reorder/renumber/deletion) — the two open design points from CONTEXT.md. - [x] D2-W1 review polish: document the one-managed-lane-per-mode-per-track exclusivity assumption in `laneModeState` (comment / debug-guard); clarify the `serialize()` one-line style note; optional round-trip tests for the last-writer-wins lane-replace contract. **Notes/decisions:** - Two new pure modules added with unit tests: `guid_diff` (diffs live track/item GUID sets between polls) and `lane_keys` (manages lane identity via durable `P_LANENAME` rather than the renumber-prone `I_FIXEDLANE` ordinal, reconciled each apply — the resolution to the lane-identity fragility design point). CTest green. - **Manual-lane protection:** a single pure predicate `isOnManualLane` is the exclusive gate; manual lanes — including REAPER's default unnamed fixed lanes — are provably never driven or auto-tagged. - **Track-level auto-tag and park behavior is live.** Item-lane show/hide is correctly structured but is a provable no-op on real projects until D2-W3 mints the `reasampler:`-prefixed named lanes. End-to-end DAW verification of item-lane show/hide is sequenced after D2-W3 for this reason. - W1 review polish was folded in during this wave. --- ## Milestone 7 — capture action family **Goal:** Bindable capture actions for master / selected tracks / selected items / razor area, each with wet-dry + tail options. CONTEXT.md §actions, Build order 7. **Verify (in DAW):** Each action registered (bindable in Actions list), routes to the offline backend, and honors wet/dry + tail. **Load-bearing principle:** none auto-inserts into the arrange. - [x] Source resolvers: master mix, selected tracks, selected items, razor area (`GetSet_LoopTimeRange`, `P_RAZOREDITS`, `CountSelectedMediaItems`, etc.). - [x] Register each as a bindable action (`command_id`/`gaccel`/`hookcommand`). - [x] Wet-dry + tail options per action. - [x] Review gate: confirm no capture path touches the timeline. **Notes/decisions:** - Four **wet** bindable capture actions — master mix, selected tracks, selected items, razor area — registered under the `CEREBELLUM_REASAMPLER_CAPTURE_*` command-id prefix. Each routes to `OfflineRenderBackend` (RENDER_* snapshot/restore, dither/normalize off, 32-bit float), produces a `Sample`, adds it to the bank, persists, and calls `MarkProjectDirty`. The M3 spike action was retired. - **Wet-only decision (Daniel):** REAPER offline render has no true pre-FX "dry" bit — the only wet/dry-adjacent lever (`&8192` pre-fader stems) is post-FX. Approximate-dry action variants were removed rather than ship a "dry" that isn't. `CaptureRequest.wetDry` is retained as the seam for true dry (M10). - Pure `render_settings` module maps source mode → RENDER_SETTINGS bits and parses `P_RAZOREDITS` (union of track-audio areas), unit-tested. No capture path inserts into the arrange (load-bearing gate); non-destructive (selection/razor read-only). **Superseded / reworked (post-landing):** - The four wet source-mode actions (master/tracks/items/razor) were replaced by **three FX-scope actions** — `capture item`, `capture track`, `capture master` — with range (razor-else-time-selection) inferred orthogonally. This fixed the defect where item captures were rendered through the parent FX chain. - **FX-scope semantics (initial rework):** item = item/take FX only; track = item FX + the selected track's own track FX; master = full chain. For item/track, the out-of-scope chain (ancestors + master, plus the item's own track for item scope) is neutralized during the render. - **Master scope subsequently removed:** capture is now **two scopes — item and track only**. `CAPTURE_MASTER` and `CAPTURE_MASTER_REALTIME` are retired (to capture the master, render a track instead). The master track is still neutralized as out-of-scope chain for both item and track captures; it is a bypass target, not a capture scope. The realtime backend taps the selected track (track scope only; item realtime deferred). - **`FxBypassGuard` (RAII):** snapshot → neutralize (FX bypassed via `I_FXEN`; gain zeroed via `D_VOL`; pan/width/pan-law/mode set to unity via `D_PAN`/`D_WIDTH`/`D_PANLAW`/`I_PANMODE`) → render → restore. Non-destructive. This guard is the reusable mechanism M8 (realtime backend) and M10 (null-test / true dry) build on. --- ## Milestone 8 — RealtimeRecordBackend **Goal:** Realtime record behind the same `ICaptureBackend`, producing identical bank entries. CONTEXT.md §capture (realtime), §Precision invariants. **Verify (in DAW):** Hidden temp track taps each selected track's own post-fader output via a `CreateTrackSend`; recorded file moves into the bank; **non-destructive** — temp track (and its sends) removed cleanly, every snapshotted track arm, time selection, and edit cursor restored unchanged on every terminal path. - [x] Track-scope tap: a `CreateTrackSend(source, temp)` from each selected track into a hidden temp track (`B_MAINSEND=0`, hidden from TCP/mixer). The temp records its own post-fader output — capturing each source track's output **after its own FX and fader, before the parent/folder/master sums it** — chain-independent by construction. No `FxBypassGuard` needed or used. Multiple selected tracks sum in the temp track (matching offline track scope). Item realtime deferred (`UnsupportedMode`). No track selected → refused. - [x] Timer-driven async state machine (`begin`/`tick`/`abort` driven by `OnTimer`, non-blocking — REAPER's UI stays responsive across the record). `begin()` validates, snapshots all state, creates the temp track, routes the tap, arms, calls `CSurf_OnRecord`, and **returns immediately**. `tick()` (called from `OnTimer`) reads the transport via `GetPlayStateEx`/`GetPlayPositionEx` scoped to the record's own `ReaProject*` (project-switch safe), advances the pure `advanceRecordPhase` state machine, and on a terminal verdict stops + finalizes/restores. `abort()` is the force-terminate path for shutdown and project switch. - [x] `RealtimeCaptureState` snapshot + idempotent restore: snapshots cursor, time selection, and every other track's `I_RECARM`; restore() is latched (`restored_` flag) and safe to call from whichever terminal path fires first. Terminal paths: normal completion, manual stop, error, second-capture reject, project switch (project-scoped `OnStopButtonEx(proj_)`, never the global `CSurf_OnStop`), **project close** (guarded by `ValidatePtr2(nullptr, proj_, "ReaProject*")` — a closed project calls `dropWithoutRestore()` rather than touching freed pointers), and extension unload. - [x] `Finalizing` flush-wait before file move: after the transport stops, `tick()` waits for the recorded file size to be positive and stable across a tick before calling `finalizeRecording` (file is no longer being written by REAPER's audio thread). A wall-clock ceiling (steady-clock, independent of the play cursor) bounds both the total record duration and the flush wait separately. - [x] Move recorded source into bank: `recordedFilePath` discovers the take's source file from the temp track's first media item; `finalizeRecording` moves it into the bank folder (cross-volume fallback: copy+remove); populates a `Sample` via `sampleFromRecordedCapture`; clean teardown via `restore()` deletes the temp track (which REAPER uses to automatically remove every send routed into it). - [x] Dialog-free; realtime is inherently non-deterministic (documented, not asserted bit-identical); saved-project gate (refuses + prompts Save-As if unsaved, matching offline). Bindable **cancel** action registered. Master scope removed entirely — to capture the master, render a track. **Notes/decisions:** - **Track scope only this increment.** Item realtime is deferred: item scope needs per-item take isolation on top of the track-output tap — a separate increment. - **TAP vs. FxBypassGuard.** The `CreateTrackSend` defaults to post-fader (`I_SENDMODE=0`) with full-stereo (`I_SRCCHAN` default): post-fader taps the source track after its own FX and fader/pan, before the parent sums it. The parent chain downstream of that branch is not in the tapped path at all — so there is nothing to neutralize and `FxBypassGuard` (which mutates the live chain, altering the user's monitoring) is deliberately not used. This also fixed the earlier silent-file bug from the spike, which sent FROM the master INTO a temp track (a feedback loop REAPER refuses, recording silence). A regular track→track send has no feedback. - **Project-close guard.** `abort()` gates every REAPER call on `ValidatePtr2(nullptr, proj_, "ReaProject*")`. A closed project already reclaimed its temp track, arms, and transport — `dropWithoutRestore()` latches `restored_` and clears `temp_` / `armSnaps_` without touching any REAPER pointer. - **No undo block.** The transient mutations (temp track, sends, arm, transport) are fully reversed by `restore()`; surfacing them as an undo point would pollute the user's history with an internal scaffold they cannot meaningfully undo. --- ## T1 — offline tail: auto (default) + manual override **Goal:** Preserve decay tails on offline captures. **Auto**: render an 8 s-capped tail, then auto-trim trailing silence to -72 dB via a **surgical** `RENDER_NORMALIZE` (only the trim-end bit, `32768`) + a derived `RENDER_TRIMEND` amplitude ratio. **Manual**: a fixed tail length clamped to the 8 s cap, no trim. **None** (default): exact bounds, byte-identical to the pre-tail capture. See `docs/product/capture-tail.md` §The offline path. **Verify (in DAW):** A range ending mid-reverb + Auto tail ends at the -72 dB decay point (not a hard 8 s, not the range end); a non-decaying signal caps at range + 8 s; **two identical Auto requests are byte-identical** (deterministic trim); a TailMode::None capture is byte-identical to the pre-tail exact-bounds capture; Manual(N ms) yields range + N ms untrimmed, with N clamped to 8000; `ScopedRenderSettings` restores `RENDER_NORMALIZE` and every touched setting on every path. - [x] Pure layer (`render_settings.{h,cpp}`): named constants `kAutoTrimThresholdDb` (-72) + derived `RENDER_TRIMEND` amplitude ratio via `autoTrimEndRatio()` (≈ 0.00025119 for -72 dB, computed as `10^(dB/20)` — `std::pow` is not `constexpr` before C++26 so this is a function, not a constant), `kMaxTailSeconds`/`kMaxTailMs` (8 s); `TailMode { None, Auto, Manual }` enum; `TailRenderSettings` struct (tailFlag/tailMs/normalize/trimEnd); `tailRenderSettingsFor(mode, manualTailMs)` mapping (None = kTailFlagNone + kNormalizeDisableAll; Auto = kTailFlagCustomBounds + kMaxTailMs + kNormalizeTrimEnd (32768) + autoTrimEndRatio(); Manual = kTailFlagCustomBounds + clamped ms + kNormalizeDisableAll); unit-tested. - [x] Wire the mapping into `OfflineRenderBackend` (`capture.cpp`): drives tail + surgical-normalize (Auto) / disable-all (Manual/None) via `GetSetProjectInfo`; `ScopedRenderSettings` snapshots and restores `RENDER_TRIMEND` alongside the existing `RENDER_*` set. `RENDER_TAILFLAG = kTailFlagCustomBounds` (1) for Auto and Manual — custom bounds is the always-applicable tail bit for offline captures. - [x] `CaptureRequest` three-state tail contract (None/Auto/Manual(ms)); default None (exact bounds, null-test-safe). The earlier `renderTail` bool/`tailMs` pair was superseded. - [x] Exposure: a **docked-panel footer toggle** (label "Tail: Off" / "Tail: Auto" / "Tail: Manual", cycles on click via `cycleTailMode`) in `bank_panel.cpp`, backed by the pure `tail_control` module (`TailSetting`, `cycleTailMode`, `clampManualMs`, `tailToggleLabel` — unit-tested). Default `TailMode::None`. `CAPTURE_ITEM` and `CAPTURE_TRACK` read the panel setting at fire time — **no per-action tail variants shipped** (the "…with tail" variants were dropped in favour of the toggle; null-test/verify captures use None explicitly). - [x] DAW-confirm: `RENDER_TRIMEND` amplitude curve (0.00025119 ≈ -72 dB); trim-end-only normalize (32768) does not engage fades/normalize/pad; trim never eats pre-`ENDPOS` body. (See spec §Open questions / DAW-confirm.) **Notes/decisions:** - **Surgical normalize.** `kNormalizeTrimEnd = 32768` sets only the trim-ending-silence bit; every other postprocessing bit is clear. A fixed-threshold trailing-silence trim scales and fades nothing, so two identical Auto requests trim at the identical sample → bit-identical repeats hold (spec §surgical normalize). - **`kNormalizeDisableAll = (4 << 16) = 262144`.** Used for None and Manual — the same disable-all value the pre-tail exact-bounds capture used. - **`tail_control` pure module** (`src/tail_control.{h,cpp}`): REAPER-free logic for the panel toggle. `kDefaultManualTailMs = 2000.0` (2 s). Fine-adjust UI (scroll-wheel in 250 ms steps) and per-project persistence landed as T1-followons (see below). - **Follow-ons resolved:** Manual fine-adjust UI and per-project persistence of the toggle landed as T1-followons. T2 realtime tail landed separately. --- ## T2 — realtime tail (follow-on to T1) **Goal:** The parallel tail path for the M8 realtime backend, which does not drive `RENDER_*`: record an 8 s-capped tail window past the range end, then **trim in a PCM decay-scan** to the -72 dB point (Manual = record fixed tail, skip the scan). See `docs/product/capture-tail.md` §The realtime path. **Verify (in DAW):** A realtime Auto capture of a decaying source records ≥ the range then trims at the -72 dB decay point (± inherent realtime tolerance); realtime tail is **not** asserted bit-identical (documented non-determinism). **Depends on:** T1, M8. - [x] Record `[start, end + clamp(tail, 8 s)]` (extend the record time selection in `capture_realtime.cpp`); Manual skips the scan, Auto proceeds to it. - [x] Pure decay-scan helper alongside `peaks`: `lastFrameAboveThreshold(interleaved, channels, frames, linearThreshold) -> frameIndex` (backward scan, per-frame max-abs across channels, no fold); unit-tested with a synthetic decaying ramp. (Spec §realtime path option (a) — recommended over bending `computeEnvelope`.) - [x] Realtime shell: read the recorded wav PCM into a float buffer, find the trim frame, rewrite the file truncated (new I/O the backend does not do today). **Notes/decisions:** - New pure module `wav_trim` (`src/wav_trim.{h,cpp}`): 32-bit-float WAV parse + header-aware truncate plan (RIFF/data size rewrite). Rejects WAVE_FORMAT_EXTENSIBLE with non-float SubFormat GUID. Depends on `peaks` for the `AudioSample` float alias. Unit-tested via a new `wav_trim_tests` CTest target. - `peaks` gained `lastFrameAboveThreshold` (backward PCM scan, per-frame max-abs across channels, no fold) for the Auto decay scan. - **Auto/Manual/Off semantics.** Auto: records `[start, end + 8 s cap]`, scans backward for the last frame above -72 dBFS, truncates the WAV header-aware at that frame. Manual: records `[start, end + fixed tail]`, skips the scan. Off: byte-identical to the pre-tail exact-bounds capture. - **Realtime tail is non-deterministic by design** (inherent to the realtime backend). Bit-identical repeats are not asserted for the realtime path; this is documented, not a defect. --- ## T1-followons — Manual fine-adjust UI + per-project tail persistence **Goal:** Close the two follow-ons deferred at T1 landing: (1) scroll-wheel fine-adjust of the Manual tail length in the panel footer; (2) the tail setting (mode + Manual length) persists per-project inside the `.rpp` rather than resetting on extension unload. **Verify (in DAW):** Scroll-wheel over the footer adjusts Manual length in 250 ms steps, clamped 0–8 s; the label reads "Tail: Manual X.Xs" (one decimal) in Manual mode; footer click still cycles Off → Auto → Manual. The tail setting survives Save / close+reopen; projects with no stored key fall back to Off / 2 s. **Depends on:** T1. - [x] `adjustManualMs(current, notches, stepMs)` pure helper in `tail_control` (per-notch ±`kManualStepMs` = 250 ms, clamped [0, `kMaxTailMs`]); unit-tested. - [x] `tailToggleLabel` updated: Manual mode appends the clamped length in seconds to one decimal, e.g. `"Tail: Manual 2.0s"`; unit-tested at boundary lengths. - [x] Panel footer scroll-wheel handler calls `adjustManualMs` and repaints; click handler unchanged (still cycles mode via `cycleTailMode`). - [x] `serializeTailSetting` / `deserializeTailSetting` pure round-trip (mode + manualMs) added to `tail_control`; unit-tested including `std::nullopt` on malformed input. - [x] `TailSetting tail_` promoted into `ReaSamplerSession` (peer to `bank_` and `view_`); `persist` serializes it under the forever-stable key `"tail_setting"` (namespace `"reasampler"`) on save and reloads it on project open. Absent key → default Off / 2 s (graceful for older/unsaved projects). - [x] Changing the toggle marks the project dirty and commits the value to ext state; `bankPanelTailSetting()` reads through the session (not a panel-local copy). **Notes/decisions:** - `kManualStepMs = 250.0` — Daniel-set coarse-but-precise step; one wheel notch = ± 250 ms. - Label format: `"Tail: Manual 2.0s"` (one decimal, `s` suffix) — format pinned by unit tests. - Default fallback on absent/malformed key: `TailSetting { TailMode::None, kDefaultManualTailMs }` (Off mode, 2 s stored length) — graceful for projects saved before this feature shipped. - `kProjExtTailKey = "tail_setting"` is forever-stable (changing it would orphan saved choices, falling back to the default — graceful but lossy). --- ## D2-W3-A — lane minting + item→lane assignment + persist round-trip **Goal:** The functional core that makes item-lanes appear: a pure `planLaneMinting` decision (which tracks hold >1 mode's content, which managed lane each item lands on) plus the shell apply path in `view.cpp` — enables fixed-lane mode, mints one managed `reasampler:`-named lane per involved mode, assigns each item (including pre-existing) to its mode's lane, and drives per-lane play state, all under one undo block, triggered off the auto-tag detection tick. Reconciles the lane-ownership index from durable lane names on project load before active-mode visibility is reapplied. The lane-ownership index persists inside the `"reasampler"` `view_state` blob (rides in `ViewModeModel::serialize()` / `deserialize()`). **Verify:** CTest green (14/14). Pure decision unit-tested in `view_mode_model_tests`. **DAW verification pending** (Daniel testing on dev): two behaviors are REAPER-runtime-only — whether lane names stick when written on the same tick the track flips to fixed-lane mode, and whether the leftover empty default lane 0 is silent. **Depends on:** D2-W2. - [x] Pure `planLaneMinting` decision (`view_mode_model.{h,cpp}`): for each reported track, collect the distinct modes of managed-eligible items; if < 2 modes, no split (D1 whole-track parking still separates stances); if ≥ 2 modes, emit one `TrackSplit`, one `LaneMint` per involved mode (durable key = `laneNameForMode(mode)`, owned by that mode), and one `LaneAssign` per managed-eligible item — including pre-existing items, so a track that just gained a second mode retroactively lanes all its content. Manual-lane items (`onManualLane = true`) are exempt at the source: never counted, never reassigned, never minted-over. - [x] Shell apply path `applyMintPlan` in `view.cpp`: enables `I_FREEMODE` = fixed lanes, grows `I_NUMFIXEDLANES` (never shrinks — user's manual lanes are never deleted), stamps each managed lane's durable name via `P_LANENAME`, records ownership in the model (`lanes().setManaged`), assigns each item to its mode's lane via `I_FIXEDLANE` resolved from the durable key. Returns `changed` so the Undo block is only kept when state actually changed (idempotent re-runs produce no undo point). - [x] Per-lane play state driven immediately after minting: `planToggle` lane ops applied via `applyLaneOps` so the freshly-minted lanes take the correct `C_LANEPLAYS` state for the active mode without a full `applyMode` re-run (which would re-park/restore whole tracks — not correct for a minting tick). - [x] `mintManagedLanes` entry point in `view.cpp`: reads live track/item picture via `readLaneTracks`, calls `planLaneMinting`, wraps the apply in one Undo block labelled `"ReaSampler: separate cross-mode content into lanes"`, calls `UpdateTimeline()` + `UpdateArrange()` after a fixed-lane mode change. - [x] `reconcileManagedLanes` in `view.cpp`: on project load, reads every fixed-lane track's `P_LANENAME` values; for each name carrying the managed prefix, records the lane as managed-for-its-mode in the ownership index — pure read of REAPER state, no lane created or renamed. Called from `main.cpp`'s load path before `applyMode`. - [x] Lane-ownership index persists via `ViewModeModel::serialize()` / `deserialize()` — the `LaneOwnershipIndex` is a member of `ViewModeModel` and round-trips inside the `"reasampler"` `view_state` key alongside modes, membership, snapshots, and active mode. No new persistence key required. - [x] Detection tick integration: `mintManagedLanes` is called from the `bank_panel` timer after the auto-tag pass, so a newly-tagged multi-mode track is split into lanes on the same tick the content is detected. **Notes/decisions:** - **Single-mode-track rule:** a track carrying content of only ONE mode is not split — D1's whole-track parking continues to separate its stance from the other mode without lane overhead. The lane-split only engages when a track genuinely holds ≥ 2 modes' content. - **Manual-lane invariant upheld at the source:** `planLaneMinting` never receives manual-lane items as split candidates. The shell's `readLaneTracks` marks items on manual lanes `onManualLane = true`; the pure decision skips them entirely. Managed lanes are always appended (tail ordinals), never overwriting a user's existing lanes. - **Idempotency:** re-reporting an already-split track produces the same plan; the shell's ensure/assign writes are no-ops when state already matches. The Undo block is closed with no label (discarded by REAPER) when the plan is non-empty but every write was already satisfied, so no phantom undo points accumulate. - **Review passed** with no Critical or Major findings. --- ## D2-W3-B — item-level mode actions + W3-A polish **Goal:** Item-level lane/mode-management actions mirroring the track-level Design View tag family (bindable in the Actions list), plus the three code-review polish items carried from D2-W3-A. The persist slice and lane-ownership index round-trip were completed in D2-W3-A; this wave closes the remaining action surface and cleans up the implementation. See CONTEXT.md §Two-canvas sub-phase (Module architecture — persistence). **Verify (in DAW):** Item mode actions registered and MIDI-bindable in the Actions list; re-drive mint/apply so each item lands on its mode's managed lane; manual-lane items exempt; one undo block per action. ctest 14/14 green. **Depends on:** D2-W3-A. - [x] "Move selected items → Design" action (`CEREBELLUM_REASAMPLER_VIEW_` family): retags selected items' membership to Design mode, re-drives the existing mint/apply so each item lands on its mode's managed lane; manual-lane items exempt; one undo block. - [x] "Move selected items → Arrange" action: retags selected items' membership to Arrange mode, re-drives mint/apply; manual-lane items exempt; one undo block. - [x] "Untag selected items" action: removes selected items' membership, re-drives mint/apply; manual-lane items exempt; one undo block. - [x] All three registered (`command_id`/`gaccel`/`hookcommand`); MIDI-bindable. - [x] W3-A polish — simplified `applyMintPlan`'s redundant `I_NUMFIXEDLANES` re-read: single grow-and-track pass removes the second `GetMediaTrackInfo_Value` call inside the mint loop. - [x] W3-A polish — extracted shared item-read seam (`src/item_read.{h,cpp}`): removes duplicated `itemGuid`/`itemLaneName` read logic from `view.cpp` and `bank_panel.cpp`. - [x] W3-A polish — added reconcile guard in `reconcileManagedLanes`: skips lanes encoding an unregistered mode id (log and skip rather than silently recording an orphaned ownership entry). **Notes/decisions:** - ctest 14/14 green; review passed with no Critical or Major findings. - **Panel UI indicator explicitly deferred** (Daniel's decision): a per-track lane-split marker has no natural cheap home in the bank panel; the mode switch already shows the active mode. Preserved as a deferred/backlog note in PLAN.md Phase D2 — not silently dropped. --- # Phase V — Versioning & release > **New pillar, own lettered namespace.** Version scheme + beta side-channel. > Namespaced **`V` (Versioning)** alongside `M`/`D`/`B`/`R` — a distinct concern > (build identity + channel isolation) that touches CMake, `main.cpp`'s > forever-stable command-id contract, and the `"reasampler"` ext-state. Product > framing + full option analysis: `docs/product/versioning-and-release.md`. > Deploy/CD wiring (two named artifacts per platform) hands off to dev-ops. ## V1/V3 — app_version module: version constant, ext-state stamp, show-version action **Goal:** Pure `app_version` module — single-source semver from CMake `REASAMPLER_VERSION "0.9.01"` via `configure_file` → `version_generated.h`; ext-state writing-version stamp under key `"version"` riding `saveToActiveProject()`; absent stamp = silent pre-versioning; on-demand `"ReaSampler: show version"` action (no startup print). New CTest target `app_version_tests`. **Verify:** CTest green. Stamp written under `"version"` key on every `saveToActiveProject()` call. Absent key classifies as `PreVersioning` (silent). Show-version action fires on demand only. - [x] `app_version` pure module (`src/app_version.{h,cpp}`): exports the CMake version string constant (`appVersion()`), the ext-state stamp value (`stampVersion()` — numeric triple only, no channel suffix), `parseVersion`, `versionLess`, `classifyWritingVersion` (empty → `PreVersioning`; unparseable → `Unknown`; well-formed → `Stamped`). No REAPER types; standard library only. - [x] `configure_file` wires `REASAMPLER_VERSION` (the one CMake variable) + `REASAMPLER_CHANNEL_IS_BETA` into `version_generated.h` in the build tree; `app_version` reads from there — one edit re-threads the version string through every consumer. - [x] Writing-version stamp: `persist` calls `SetProjExtState` under `kProjExtVersionKey` (`"version"`) with `stampVersion()` inside `saveToActiveProject()` on every save. Absent key on load → `PreVersioning` (silent; graceful for pre-versioning projects). - [x] On-demand show-version action (`channelCommandId("SHOW_VERSION")` / `channelActionName("show version")`): prints the CMake-sourced `appVersion()` string to the console when fired. **No unconditional startup print** (no version line added to the extension load message). - [x] `app_version_tests` CTest target: version parse/compare/classify round-trip; `PreVersioning` on empty; `Unknown` on malformed; `Stamped` on well-formed; `versionLess` numeric ordering (10 > 9, not lexicographic). ## V4 — beta-in-isolation: fully isolated coexisting binary via compile-time channel flag **Goal:** Compile-time channel flag `-DREASAMPLER_CHANNEL=beta` → fully isolated `reaper_reasampler_beta` binary: ext-state namespace `reasampler_beta`, FOREVER-STABLE command-id prefix `CEREBELLUM_REASAMPLER_BETA_`, `"ReaSampler beta: "` action names, channel-qualified dock title/ident, `0.9.01-beta` display render, bank-panel footer version/channel readout. Stable build byte-identical to prior identity. **Verify (in DAW):** Both binaries load simultaneously in one REAPER via the startup dlopen. Stable produces no change to any existing action id, ext-state key, or panel string. Beta reads/writes only `"reasampler_beta"` namespace; its actions carry `CEREBELLUM_REASAMPLER_BETA_` prefix; its panel shows `0.9.01-beta`. No shared-state collision path between channels. - [x] `app_version` extended as the single source of truth for channel identity (V4): `channel()`, `isBeta()`, `extStateNamespace()`, `commandIdPrefix()`, `actionDisplayPrefix()`, `binaryName()`, `dockTitle()`, `dockIdent()` — all derived from the one `REASAMPLER_CHANNEL_IS_BETA` bit. Stable values byte-identical to pre-V4 build. - [x] `channelCommandId(suffix)` / `channelActionName(phrase)` composition helpers: every action-registering shell funnels through these so no shell re-implements the channel-qualified concatenation. FOREVER-STABLE per channel. - [x] `configure_file` threads `REASAMPLER_CHANNEL_IS_BETA` (0 for the default build, 1 for `-DREASAMPLER_CHANNEL=beta`) alongside the version string. Beta binary name, namespace, prefix, and display suffix all derive from this one bit. - [x] All shells (`main.cpp`, `actions.cpp`, `bank_panel.cpp`, `persist.cpp`) updated to compose ids/names via `channelCommandId`/`channelActionName` and read `extStateNamespace()` — no scattered `#ifdef` forks in the shells. - [x] Bank-panel footer version/channel readout: displays `appVersion()` (stable: `"0.9.01"`, beta: `"0.9.01-beta"`). - [x] The lane-name `reasampler:` prefix is deliberately NOT channel-qualified (shared naming convention; ownership isolated by namespace). - [x] Stable build: byte-identical to pre-V4 identity on every string that was previously shipped. **Notes/decisions:** - The stamp value (`stampVersion()`) is the numeric triple only on BOTH channels — no `-beta` suffix in the stamp. The channel is carried by the isolated namespace (`extStateNamespace()`), not baked into the stamp, so the stamp parses as `Stamped` on read-back and stable's stamp is byte-identical regardless of channel build. - Two permanent commitments accepted: a second forever-stable command-id prefix (`CEREBELLUM_REASAMPLER_BETA_`) and a second ext-state namespace (`"reasampler_beta"`). Beta keybindings are a distinct forever-family from stable's. - Isolation semantics (accepted, not a bug): a channel reads/writes only its own namespace — a stable project looks empty/default when opened in beta, and vice versa. No cross-namespace read, migration, or fallback. - Deploy implication (dev-ops hand-off): two named artifacts per platform (`reaper_reasampler` + `reaper_reasampler_beta`), built by toggling `-DREASAMPLER_CHANNEL`. --- # Phase B — Multi-bank (parallel to the M0–M11 capture roadmap and Phase D) > **Separate phase namespace.** The M-numbers belong to the capture pillar > (M0–M11); the D-letters belong to Design View. Multi-bank is a third orthogonal > pillar — generalizing the single bank into a pool + named banks — so it takes its > own **lettered** namespace (B1, B2, …). "B" reads for **Banks** and, like Phase D, > keeps the roadmaps from colliding on numbering: Phase B is not "the twelfth > capture step," it is a different pillar. Authoritative spec: **CONTEXT.md > §Multi-bank**. Product framing: `docs/product/multi-bank.md`. ## B1 — bank_book (pure) **Goal:** REAPER-free bank registry wrapping N `BankIndex` instances: pool seeded + privileged, create/rename/reorder/delete named banks, active-bank id, move/copy a sample between banks, JSON round-trip + legacy-migration. The heart of the phase; mirror of `bank_model` / `view_mode_model`; **`BankIndex` untouched (additive)**. CONTEXT.md §Multi-bank (Module architecture — pure). **Verify:** CTest green. Pool always present, un-deletable, un-renamable, un-evacuable (rules rejected in-model). Active-bank defaults to pool. Move is index-only (source loses entry, destination gains it) and observes destination collapse-by-hash; copy leaves source intact. Delete drops member index entries. Evacuate moves all members to the pool, leaving the bank empty. JSON round-trip lossless across pool-as-bank-zero + named banks + per-bank indices + ordinals + active id. Legacy `bank_index` JSON parses into `{ pool }` with zero named banks. - [x] Bank registry: ordered `{ bank id, display name, ordinal, BankIndex }`; pool seeded with fixed id + fixed name; create / rename / reorder / delete named banks (delete drops the bank's member index entries). - [x] Pool-privilege rules enforced in-model: reject delete-pool, reject rename-pool, reject evacuate-pool, never allow zero banks. - [x] Active-bank id (get/set; defaults to pool); resolve active bank's `BankIndex`. - [x] Move sample between banks (index-only; destination collapse-by-hash observed; source entry removed). - [x] Copy sample between banks (index-only; source entry retained; destination collapse-by-hash observed). - [x] Evacuate bank: move every member to the pool (index-only; destination collapse-by-hash observed), leaving the bank empty; pool cannot be evacuated. - [x] JSON round-trip: pool-as-bank-zero inside the blob + named banks + per-bank indices + ordinals + active id. - [x] Legacy migration: a bare `bank_index` JSON promotes to the pool's index with zero named banks (one-way, lossless; blob authoritative thereafter). - [x] Tests: pool privileges (delete/rename/evacuate rejected); move source-loses/dest-gains; copy source-retained; evacuate empties source into pool with dest collapse; cross-bank same-hash coexistence; dest collapse on move into a bank already holding the hash; JSON lossless; legacy migration. > **Phase-B-wide undo (fork R-B, settled 2026-07-24 — batched REAPER undo points).** > Every index verb across B1–B5 (create/rename/reorder/delete-bank, move, copy, > evacuate, remove) wraps its bank/index mutation in a **batched REAPER undo point** > (`Undo_BeginBlock` / `Undo_EndBlock`), so one bank operation is one Ctrl-Z. This is > a cross-cutting decision that retro-touches B1–B4, not a B5-local one; the > per-verb points above inherit it. **Must-verify before build:** confirm against > `vendor/reaper-sdk` that `"reasampler"` ext-state mutations participate correctly > in `Undo_BeginBlock`/`Undo_EndBlock` undo blocks — the whole approach depends on > it. See CONTEXT.md §Sample removal (Guardrails) + product notes §Fork R-B. **Notes/decisions (R-B — Phase-B-wide undo, landed):** - Every bank index verb — bindable action AND panel gesture (menu/drag/Delete key) — is one batched REAPER undo point (`Undo_BeginBlock2`/`EndBlock2`, `UNDO_STATE_MISCCFG`); ext-state participates in undo via `UNDO_STATE_MISCCFG` ("extensions!"), SDK-verified. A `projectconfig` hook (`BeginLoadProjectState(isUndo)`) triggers a deferred session reload so Ctrl-Z/redo visibly restores book/view/tail/manifest in-session. Rejected/no-op ops open no undo point; unsaved-project ops discard the empty block. --- ## B-cap — owned-file manifest seam (capture writes; prune consumes in Phase R) **Goal:** Capture writes each file it creates into an **owned-file manifest** persisted in the `"reasampler"` ext-state, so Phase R prune can later distinguish the bank system's own orphans from hand-dropped files. Consumed only in Phase R (R1/R2) — landed early here because reconstructing the manifest retroactively is a backfill cliff (fork R-D, settled 2026-07-24: *defer the feature, design the seam*). CONTEXT.md §Prune (Settled decisions — orphan attribution) + product notes §Fork R-D. **Verify:** every file the capture path creates is recorded in the owned-file manifest; the manifest round-trips through the `"reasampler"` ext-state (Save / Save As / reopen); relative-paths-only preserved. Prune's consumption of it is Phase R. **Depends on:** the capture add-path (M7) + persist blob machinery (M4 / B2). - [x] Capture records each created file into an owned-file manifest (the set of files the book has created), persisted in the `"reasampler"` ext-state (sibling `owned_files` key — persistence shape resolved at build time: sibling key, not folded into the `banks` blob). - [x] Manifest round-trips: survives Save / Save As / reopen via the M4 blob machinery; relative-paths-only. (Consumed by Phase R R1/R2 — not consumed here.) **Notes/decisions:** - Pure `owned_manifest` module (`src/owned_manifest.{h,cpp}`): relative paths, dedup, JSON round-trip. Deliberately decoupled from `bank_book` — tracks files created, not index membership; sample-remove is not manifest-remove. Unit-tested via new `owned_manifest_tests` CTest target. Persisted under the `"owned_files"` ext-state key. Both capture commit paths (offline + realtime) record created files. Joins the undo-reload set. --- ## B2 — persist slice (banks ↔ project ext state) **Goal:** Serialize the book under the `banks` key in `"reasampler"` alongside the existing sections, with the pool folded in as bank-zero; migrate a legacy `bank_index` key into the pool on first load and retire the legacy key; reload-on-open and Save-As survival via the existing M4 machinery. CONTEXT.md §Multi-bank (persist). **Verify (in DAW):** Banks + named banks + active bank + all per-bank samples survive Save / Save As / close+reopen; **relative paths only**; bank travels with the `.rpp`; a project saved before this phase (legacy `bank_index` only) loads as pool + zero named banks with no sample loss, and after save carries `banks` with no `bank_index` written. **Depends on:** B1. (Persistence-key fork settled — fork 1 (a): pool inside the `banks` blob, legacy key retired after one-way migration.) - [x] Serialize/deserialize the book under the `banks` key (pool-as-bank-zero inside the blob; distinct section from `view_state`; no `bank_index` key written going forward). - [x] Legacy-migration path on load: absent `banks` + present `bank_index` → promote into pool, mint the blob, treat blob as authoritative (legacy key retired). - [x] Session exposes the book; the active bank's `BankIndex` is the capture add target (route the M7 capture family through it — additive to M7, no M7 rewrite). - [x] Confirm survival across Save / Save As; confirm legacy-project load path. --- ## B3 — actions **Goal:** Bindable action set for the multi-bank workflow. CONTEXT.md §Multi-bank (actions). **Verify (in DAW):** Each action registered (bindable in Actions list); bank-activate + move/copy + evacuate MIDI-bindable; create/rename/delete/evacuate drive the B1 model via the B2-persisted session. **Depends on:** B1, B2. - [x] Create bank / rename bank / delete bank (delete drops member index entries; confirm-on-non-empty offered at the UI layer in B4). - [x] Evacuate bank → pool (move all members back to the pool; refuses on the pool). - [x] Activate bank (direct-by-id + cycle). - [x] Move selected samples → bank / copy selected samples → bank (move is default). - [x] Pool full-height / banks full-height toggles. - [x] Register each (`command_id`/`gaccel`/`hookcommand`); bank-activate + move/copy + evacuate MIDI-bindable. --- ## B4 — bank_panel vertical split (UI) **Goal:** The vertical-split bank window — pool on top, named-banks tab-page region below, full-height toggles — extending the M5 docked grid. CONTEXT.md §Multi-bank (bank_panel). **Verify (in DAW):** Pool grid renders on top; named-banks tab strip below (empty when no named banks, one tab per named bank); active-bank **unmistakably** indicated; both full-height toggles collapse the split correctly; sample move/copy affordance works; non-empty delete confirms and offers evacuate; the Design View mode switch in the header is unaffected. **Depends on:** B1, B2, B3. (Tab rendering + move-affordance mechanics — fork 5 — settled 2026-07-23: LICE-drawn tabs + both move affordances; see Phase B open questions and product notes → *Fork 5 — settled*.) - [x] Vertical split: pool grid region (top) + named-banks tab-page region (bottom). - [x] Named-banks tab strip: **LICE-drawn** (matching the M5 grid + Design View segmented switch, not SWELL-native — fork 5a); one tab per named bank; empty state when none. - [x] Tab-strip overflow/scroll affordance (fork 5a): scroll/chevron overflow shipped with the strip. - [x] Pool full-height / banks full-height toggle affordances wired to B3. - [x] Active-bank indicator — **visually unmistakable** (settled constraint). - [x] Create / rename / delete / activate / evacuate affordances driving B3 actions. - [x] Delete confirms on a non-empty bank, naming the evacuate alternative. - [x] Sample move affordance — **both** (fork 5b): a "move to bank" menu on the current selection (bindable front-end for the B3 move action) **and** drag-between-regions. Copy is the deliberate secondary act, offered on the menu. - [x] Drag mis-drop mitigation (fork 5b): clear drop-target highlighting on the destination region/tab during a drag. **Notes/decisions:** - New pure module `src/tab_strip.{h,cpp}`: named-banks tab-strip geometry (B4) — strip rect + N tabs + scroll offset → per-tab rects (overflow-clipped), overflow chevron reservation + maxScroll, and point → tab/chevron hit-test. Unit-tested via `tab_strip_tests` CTest target. Mirror of `mode_switch`. - Active-bank indicator placement (the open polish detail from the Phase B open questions) was resolved at build time in the panel implementation. - Post-landing: m11's console-chatter policy applied to Phase B messages (successes silent, failures kept). --- ## B5 — sample-remove (the missing sample-level verb) **Goal:** Drop an individual `Sample`'s index entry from a bank or the pool — the sample-level companion to move/copy/evacuate/delete-bank. Index-only, non-destructive to the file; exposes the `BankIndex::remove` primitive that `bank_model` already has (wires it, does not add it). CONTEXT.md §Sample removal. Product framing + open forks: `docs/product/removal-and-prune.md` §Sample-remove. **Verify (in DAW):** Remove drops the selected sample's entry from the target bank; a same-hash entry in another bank is untouched (no cross-bank dedup); pool *contents* are removable while pool-container privileges hold; removing the last index reference to a file leaves that file on disk (orphaned until prune — never deleted by remove); non-destructive (index + ext-state only, no file, no timeline item). **Depends on:** B1, B2, B3 (action set), B4 (panel affordance). - [x] Surface `BankIndex::remove` through `bank_book`: remove a `Sample` from a bank's index; pool contents removable, pool-container privileges unchanged. - [x] "Remove selected sample(s)" action (`command_id`/`gaccel`/`hookcommand`), MIDI-bindable; carries a `scope: this-bank | all-banks` seam (fork R-A, settled 2026-07-24: **this-bank** is the default and only surfaced affordance; all-banks stays a latent seam-only parameter, not shipped). - [x] `bank_panel` remove affordance on the current selection (reuse M5 selection model, as move/copy do). - [x] Silent remove: no confirm dialog; recoverability via batched REAPER undo (R-B) — one Ctrl-Z restores the index entry; files are never deleted by remove. - [x] Tests: remove drops the target entry; same-hash entry in another bank survives; remove-from-pool allowed; last-reference remove leaves an orphan (file untouched); non-destructive (no file/timeline mutation). **Notes/decisions (B5 forks — settled 2026-07-24):** - **R-A — remove scope.** Settled: **this-bank**. Removes the entry from the bank in view only; the `scope: this-bank | all-banks` seam stays in the action signature but all-banks is a latent parameter, not a surfaced verb. - `hashReferencedElsewhere` cross-bank reference query in `bank_book` retained as a tested model API for Phase R prune; the remove shells no longer call it. - Both `bank_panel` context menu ("Remove selected sample(s)") and Delete key affordance wired; panel gesture is also one batched undo point (R-B applies). --- ## Milestone 10 — provenance (re-capture from source) **Goal:** Populate `Sample.provenance` (parent sample id + a capture-recipe fingerprint) on resample-from-sample, and ship a **"re-capture from source"** action that regenerates a sample from its recorded source. Reconciled with the dual-canvas (Phase D2) model. CONTEXT.md §Data model, §capture; product framing + the settled reconciliation in `docs/product/provenance.md`. **Verify (in DAW):** A sample resampled from a bank sample carries its parent id + recipe fingerprint; "re-capture from source" regenerates the file into the bank (never auto-inserting into the timeline — load-bearing principle); re-capture with an unchanged source + request is byte-identical to the original (bit-identical repeats); non-destructive to source items/tracks. > **Reshaped from the old "provenance + null-test verify" M10.** **Cut (fixed by > Daniel):** the null-test verification *action* and the true-pre-FX-dry *mechanism* > the old note required — both dropped, see `docs/product/provenance.md` §What was > cut. **Kept:** provenance + re-capture. The `Sample.provenance` struct and its JSON > round-trip **already exist** (M1) — M10 populates and consumes the field, it does > not add it. Fork picks settled by Daniel (2026-07-23): **P1=a thin fingerprint, > P2=a bank-only re-capture**; P3/P4 moot under P2=a. - [x] Populate `Sample.provenance` on resample-from-sample: `parentSampleId` (the bank sample the capture derived from) + `fxChainSnapshot` as a **thin capture-recipe fingerprint** (scope + source FX-chain identity/hash at capture time — a drift/repro fingerprint, NOT a serialized pre-FX-dry chain to restore; P1=a settled). - [x] "Re-capture from source" action (`RECAPTURE_FROM_SOURCE`, channel-composed): regenerate a provenanced sample by re-running its recorded capture request against the source's **current** state; update the bank file + Sample in place (`BankIndex::updateInPlace` / `BankBook::updateSampleInPlace` — order-preserving, id-stable; old file becomes a Phase R orphan). **Bank-only — never inserts/re-places into the timeline** (load-bearing principle). Reports drift if the source changed since capture. - [x] Verify: re-capture of an unchanged source is byte-identical to the original capture (bit-identical repeats); non-destructive (`FxBypassGuard` snapshot/restore as M7); relative-paths-only preserved. **Dual-canvas reconciliation (settled — `docs/product/provenance.md`):** With bank-only re-capture (P2=a), provenance is **pure per-sample bank metadata**, `bank_model` and `view_mode_model` **stay decoupled**, and M10 touches **no** canvas code. Dual-canvas compliance is satisfied by staying on the right side of the capture-never-places line — not by any new coupling. Forks P3 (canvas/lane memory in provenance) and P4 (re-capture auto-tag interaction) were only live under re-capture-and-replace (P2=b) and are **closed as moot**; if the user manually re-places a regenerated sample, the existing D2 mode-aware placement rule governs. **Notes/decisions:** - Pure `provenance` module: `rsprov1` length-prefixed encoding; captures scope, exact range, tail, rate/channels, track GUIDs, order-sensitive FX-chain identity; parse/compare for drift detection. NOT a serialized chain to restore. - `provenance_shell`: FX-chain identity queries via `TrackFX_*` / `TakeFX_*`; source-item path collection; parent-detection inputs. Item scope fingerprints take FX via `TakeFX_*`; track scope fingerprints track FX. Stamps `Sample.provenance` when every resolving source item maps by exact normalized path (case-folded on Windows) to exactly one bank sample — ambiguous/mixed cases conservatively record nothing. - **Cut items (correct per spec, not built):** the null-test verification action and the pre-FX dry path. Both were explicitly removed at M10 reshaping (`docs/product/provenance.md` §What was cut). - New CTest target `provenance_tests`. --- ## Phase B open questions — all resolved All five forks settled by Daniel (2026-07-23): persistence key = fold pool into `banks`, retire legacy key (1a); delete drops members + add evacuate verb (2); move is the default gesture (3); active-bank/shown-tab distinct with an unmistakable indicator (4); LICE-drawn tabs + overflow, and both move affordances with drop-highlighting (5). Active-bank indicator placement (the one residual polish detail) was resolved at build time. B5 forks R-A and R-B settled 2026-07-24 (see B5 and B1 notes above). Both in `docs/product/removal-and-prune.md` §Fork R-A / §Fork R-B. --- # Phase R — Reclaim (file lifecycle: the prune path) > **New pillar, own lettered namespace.** Prune is the file-lifecycle path the > capture and multi-bank specs forward-reference throughout ("files persist on disk > until prune") but that had no phase, module, or point. It is the **only** operation > in ReaSampler that deletes bytes off disk. Namespaced **`R` (Reclaim)** alongside > `M`/`D`/`B` because it is a distinct pillar — it serves *every* orphan-producing > path (delete-bank, sample-remove B5, potentially M10 re-capture), not just > Multi-bank, and it carries a new risk class (file deletion) with its own > invariants. Authoritative spec: **CONTEXT.md §Prune — file-lifecycle spec**. > Product framing + phase-placement justification + forks: > `docs/product/removal-and-prune.md` §Prune. > > **Boundary (load-bearing):** *remove creates orphans; prune reclaims them.* No > operation other than prune deletes a file; prune deletes only files no index > references. A bank op that deletes a file is still a bug. > > **Depends on:** B1, B2 (needs the multi-bank book to union the referenced-set > across all banks) and B5 conceptually (sample-remove is a primary orphan-producer, > so remove-then-prune is the coherent pair — mirror of evacuate-then-delete). Does > **not** depend on the B3/B4 UI. ## R1 — prune-reconcile core (pure) **Goal:** REAPER-free, filesystem-free reconciler — given the files present in the bank folder, the files referenced by the book (unioned across all banks, pool included), and the **owned-file manifest** (fork R-D, written from capture onward by B-cap), compute the orphan set `(owned ∩ present) − referenced`. The mirror of `ViewModeModel::reconcile(liveGuids)`, one level down (files instead of GUIDs). CONTEXT.md §Prune (Module architecture — pure). **Verify:** CTest green. **Prune null test:** a folder whose every file is referenced deletes nothing; prune returns exactly `(owned ∩ present) − referenced` and nothing else. Referenced-set unioned across every bank (a file referenced by any bank — including via a copy — is never an orphan); a present-but-not-owned file (a hand-dropped file) is never an orphan. - [x] Prune-reconcile pure function: `(present, referenced, owned) → orphans`, computing `(owned ∩ present) − referenced`; referenced unioned across the whole book (copies keep a file alive). - [x] Tests: prune null test (all-referenced → empty); orphan = (owned∩present)− referenced; a copied file referenced by a second bank survives; a present-but- unowned (hand-dropped) file is never reclaimed; empty folder / empty book / empty manifest edge cases. **Notes/decisions:** - New pure module `src/prune_reconcile.{h,cpp}`: exports `pruneOrphans(present, referenced, owned)` (the safety-critical set algebra), `buildPruneReport` (count/bytes/display-capped list, unit-testable), and `pruneDeletePlan` (the R3 confirm-time staleness intersection — `confirmed ∩ freshOrphans` in confirm order). Exact-string path match throughout (no case-folding, no separator normalization). `BankBook::referencedPaths()` additive const union query (all banks incl. pool, de-duped) added to `bank_book`. New `prune_reconcile_tests` CTest target. --- ## R2 — prune shell + persist wiring (filesystem I/O, thin) **Goal:** Enumerate the current project bank folder (M4 project-relative resolution), supply the referenced-set and the **owned-file manifest** (from B-cap) from the session, feed the pure core, and produce a dry-run manifest. No deletion in this wave — the report path only. CONTEXT.md §Prune (persist / prune shell). **Verify (in DAW):** Dry-run reports the orphan count + reclaimed size (+ file list for a small set) against the resolved current bank folder; resolves paths the same way the index does (survives a Save-As relocation); deletes nothing. **Depends on:** R1, B1, B2. - [x] Prune shell: enumerate the resolved current bank folder; feed the pure core. - [x] Session supplies the referenced-set (union across the book) **and the owned-file manifest** (written by B-cap); resolve the bank folder via the M4 project-relative machinery. - [x] Dry-run manifest: orphan count + reclaimed size (+ files for a small set); **no deletion in this wave.** **Notes/decisions:** - `ReaSamplerSession::pruneDryRun()` (read-only, non-throwing) enumerates the resolved current bank folder, unioning `book().referencedPaths()` and `owned().paths()`, feeds `pruneOrphans`, and calls `buildPruneReport` with a 64-file display cap. Pure `bankRelativeForName` (`capture_paths`) normalizes the folder- enumeration spelling to match the index convention so the pure core's exact-string match lines up. Forever-stable `BANK_PRUNE_FOLDER` action registered (dry-run report to console in R2; deletion wired in R3 behind the same action id). --- ## R3 — deletion + action (the destructive step, guarded) **Goal:** The confirmed deletion step, the bindable "Prune bank folder" action, and a `bank_panel` prune button: dry-run-first, confirm-with-manifest, then reclaim the orphan set — via OS trash where portably available (fork R-C), else unlink. CONTEXT.md §Prune (guardrails, API). **Verify (in DAW):** "Prune bank folder" (action or panel button) reports first, deletes only on explicit confirm, and reclaims exactly the orphan set — never a referenced file, never a hand-dropped non-bank file; the referenced/owned-set safety holds; deletions route to OS trash where available; non-bank and capture invariants untouched. **Depends on:** R2 (and B-cap's owned-file manifest). All forks settled 2026-07-24. - [x] "Prune bank folder" action (`command_id`/`gaccel`/`hookcommand`), dry-run-first, confirm-to-delete. - [x] `bank_panel` prune button (fork R-E) that fires the "Prune bank folder" action through the existing command-id contract — the panel affordance alongside the bindable action; split: button hit-test/layout is pure (mirror of `mode_switch`/`bank_grid`), draw + dispatch is bank_panel shell. - [x] Deletion mechanism (fork R-C, settled trash-preferred): route to OS trash where a portable move-to-trash is verified available, else unlink behind the dry-run/confirm guardrail. - [x] Orphan attribution (fork R-D, settled owned-file manifest): reclaim only `(owned ∩ present) − referenced` — the bank system's own leavings, never a hand-dropped folder file. (Manifest written by B-cap; consumed via R1/R2.) **Notes/decisions:** - Deletion is guarded: dry-run → REAPER `ShowMessageBox` confirm (count+bytes+files) → `pruneDeletePlan` staleness intersection (confirmed ∩ fresh pure-core output) → delete exactly the plan. Zero ext-state writes, no undo point (file deletion is not REAPER-undoable by design). - **Windows:** routes to Recycle Bin via `SHFileOperationW` + `FOF_ALLOWUNDO` (verified against SDK 10.0.26100). **macOS / Linux:** no portable SWELL trash surface; falls back to `unlink` behind the dry-run/confirm guardrail. - Manifest entries are deliberately NOT removed on deletion (the owned-file manifest algebra self-cleans: a deleted file will drop from `present` on the next prune scan, and `pruneOrphans` returns `(owned ∩ present) − referenced` — the absent file contributes nothing regardless). - New pure module `src/prune_button.{h,cpp}`: layout (`computePruneButton`) and hit-test (`hitTestPruneButton`) for the footer prune button, right-anchored, suppressed gracefully when the footer is too narrow. Mirror of `mode_switch` / `tab_strip`. New `prune_button_tests` CTest target. - `bank_panel` footer button dispatches `BANK_PRUNE_FOLDER` via `Main_OnCommand` through the registered command id (the same action as the bindable menu entry — no duplicate logic). --- ## Milestone 11 — polish (wave 1) **Goal:** Batch capture (per selected item / per razor area), action trigger buttons + keybinding help labels, conform-on-insert. CONTEXT.md Build order 11. **Verify (gates 23/23 both configs):** CTest green on new pure targets; each in-panel action fires through the command-id contract without regressing precision invariants. - [x] **Batch capture** (`batch_capture` pure module + `CAPTURE_BATCH_ITEMS` / `CAPTURE_BATCH_RAZOR` actions): plan source ranges → ordinal units; mixed-result aggregation; actions fire one bank sample per selected item / per razor area; transient per-unit selection with RAII restore; per-unit invariants + provenance; single persist per batch; one summary line. `RunCapture` internals extracted to a shared `captureAndIndexOne` helper (behavior identical). New CTest target `batch_capture_tests`. - [x] **Action trigger buttons + keybinding help labels** (`action_buttons` pure module + bank_panel strip): strip layout/hit-test with min-width overflow-hiding and label formatting with "(unbound)" fallback; struct `ActionButtonRect`; a 28px LICE button strip in bank_panel between the split body and tail footer fires capture item/track, realtime start/cancel, insert native/conform, re-capture-from- source via `NamedCommandLookup` + `Main_OnCommand`; labels show live bindings via `kbd_getTextFromCmd`. Coexists with Phase R's prune button (footer). New CTest target `action_buttons_tests`. - [x] **Conform-on-insert** — verified already shipped (both insert variants were registered actions since the insert milestone); no new code. Closes as verified-extant. - ~~[ ] **Resample-and-mute-source** — Cut (fixed by Daniel, 2026-07-26).~~ Rationale: the Design View mode projection (park/hide inactive-mode content) supersedes the mute-after-capture workflow; a mute action would be redundant with the dual-canvas architecture. Mirror of the null-test cut precedent ("Cut (fixed by Daniel)"). ## Milestone 11 — polish (wave 2 / completion) **Goal:** Native OS drag-out — the final M11 polish item. CONTEXT.md Build order 11, §Non-goals (drag-out deferred to last). **Verify (gates 24/24 both configs):** Drag-out places a valid file in the OS target without regressing the precision invariants; copy-only semantics throughout (no source deletion on drop); internal move/copy drag unchanged. - [x] **`drag_out` pure module** (gesture-boundary decision): internal drag becomes OS-bound when the pointer leaves the panel client rect; path-list assembly with dedupe and missing-file skip. No REAPER types at the boundary. New CTest target `drag_out_tests`. - [x] **`drag_out_win` shell** (Windows): OLE `DoDragDrop` / `CF_HDROP`. Copy-only structurally — `DROPEFFECT_MOVE` is not offered and no source-deletion path exists; prune remains the sole file-deleter. macOS/Linux via `SWELL_InitiateDragDropOfFileList` with a documented copy-semantics caveat (SWELL does not expose a drop-effect query). - [x] `bank_panel` additive hook only — internal move/copy drag unchanged. **Notes/decisions:** - Copy-only is structural, not a policy flag: `DROPEFFECT_MOVE` is never offered on Windows, so the OS never signals a move. The SWELL path cannot query drop-effect; copy semantics are documented as a known caveat for macOS/Linux. - Prune remains the sole authority for deleting files off disk; drag-out does not remove the source file or any bank index entry. - This wave completes **Milestone 11** in full. ## Phase R forks — settled 2026-07-24 - **Fork R-C — deletion mechanism.** Settled: **trash-preferred, unlink fallback.** Route to OS trash where a portable move-to-trash is available (recoverable), else unlink behind strong dry-run/confirm. Per-platform trash surface verified at build: Windows `SHFileOperationW` + `FOF_ALLOWUNDO` (SDK 10.0.26100); macOS/Linux no portable SWELL trash surface → unlink fallback. Folded into R3. - **Fork R-D — orphan attribution.** Settled: **owned-file manifest**, `(owned ∩ present) − referenced`; folder-sweep rejected as unsafe. **Seam lands early** — the manifest is written from capture onward (new **B-cap** point in Phase B), not reconstructed at prune time; R1/R2 consume it. Persistence shape (sibling `"reasampler"` key vs. `banks` blob) resolved at build time: sibling `"owned_files"` key. - **Fork R-E — trigger.** Settled: **manual action + `bank_panel` button**, dry-run-first, confirm-to-delete. No background sweep. The earlier optional delete-time "…and prune now" convenience was **not** selected — out of scope. Folded into R3. Both docs of record: `docs/product/removal-and-prune.md` §Fork R-C/R-D/R-E and CONTEXT.md §Prune (Settled forks). --- # Phase L — Look-and-feel (system-wide visual design language) > Separate phase namespace. Namespaced **`L` (Look-and-feel)**, orthogonal to and ungated > by the M/D/B/R/V/S pillars. Authoritative spec: **CONTEXT.md §Phase L**. Product > framing + settled decisions (DS-1/DS-2/DS-3): `docs/product/visual-design-language.md`. ## L1 — shared LICE drawing kit (the foundation) **Goal:** Stand up the shared LICE-based drawing kit that every Phase L surface (L2 dock panel, L3 VST editor) consumes — palette/theme module, pure component geometry/hit-test helpers, LICE draw shell, and retirement of the GDI `DrawText` path in `bank_panel`. CONTEXT.md §Phase L (Kit architecture). DS-1 (LICE + WDL, no external frameworks) and DS-2 (Direction B Neon Console + Direction C spectral) are the governing settled decisions. **Verify:** CTest green (`theme_tests`, `component_geometry_tests`). Each text-on-surface pair in the palette clears its WCAG floor (tested). `bank_panel` text routes through cached-font `text()` — GDI `DrawText` path retired. Double-buffer discipline preserved. - [x] **`theme`/palette module** (`src/theme.{h,cpp}`): role→color mapping via one constants block (DS-2 revised — REAPER-grey neutral ladder + three-accent pastel system: `bg/base #2b2b2b` / `bg/panel #333333` / `bg/cell #3a3a3a` / `line/hairline #4a4a4a` / `text/primary #dcdcdc` / `text/dim #a8a8a8`; `accent/primary` pastel lime `#B0E098` = live/active/selected, `accent/secondary` pastel teal `#84D6D0` + `accent/tertiary` pastel purple `#C2AAE8` = categorical distinctions); `Role` enum carries the three accent roles; `roleColor`/`roleColorState` updated (Active/Dragging/ Focus → primary accent); `spectralColor` is a pastel three-stop sweep anchored on the three accents (lime → teal → purple); WCAG contrast-floor helpers + tests (text/dim-on-grey AA body; three pastels on bg/cell + bg/panel at the 3:1 floor); interaction-state color model. Pure; no LICE types. New CTest target `theme_tests`. - [x] **`component_geometry` module** (`src/component_geometry.{h,cpp}`): button/slider/ list-row geometry + hover hit-test. Pure; no LICE or REAPER types. New CTest target `component_geometry_tests`. - [x] **`draw_kit` shell** (`src/draw_kit.{h,cpp}`): LICE draw layer — `fillSurface` (micro-gradient + inner highlight/shadow), `drawButton`/`drawSlider`/`drawListRow`/ `drawWaveform`, cached-font `text()` over four `LICE_CachedFont`s (kit-owned lifecycle), full interaction-state model, double-buffer preserved. - [x] **GDI `DrawText` retirement in `bank_panel`**: all panel text now routes through the kit's cached-font `text()`; raw GDI `DrawText` path retired (the single biggest "temple os → modern" lever). --- ## L2 — dock-panel layout redesign **Goal:** Full layout redesign of the `bank_panel` dock window — task-grouped action bar, full M11-aware button inventory placed by cluster, entire panel drawn through the L1 kit. CONTEXT.md §Phase L (L2 scope). DS-3 (thorough layout redesign, not a light re-skin) is the governing settled decision; sequenced after M11 merged. **Verify:** CTest green (`action_bar_tests`). The panel renders in the settled B+spectral language through the L1 kit — chrome, buttons, tabs, grid cells, dividers all by palette role with hover on interactive elements; remaining GDI text retired; single `KitColor→LICE_pixel` boundary via the kit's `toLice` (exposed in `draw_kit.h`). Prune button remains footer-set-apart + `warn`-colored; grid stays the centerpiece. - [x] **`action_bar` pure module** (`src/action_bar.{h,cpp}`, `tests/test_action_bar.cpp`, CTest target `action_bar_tests`): task-grouped action-bar layout — clusters (Capture / Placement / Maintenance), per-button label + keybinding micro sub-rects, whole-trailing- button overflow, point→index hit-test. Pure; no LICE or REAPER types. Mirror of `mode_switch`/`bank_grid`/`action_buttons`. - [x] **`bank_panel` redesigned**: full M11-aware button inventory placed and grouped by task cluster (Capture: capture item/track, batch items, batch razor, realtime; Placement: insert + insert-conform; Maintenance: re-capture, cancel-realtime); prune remains footer- set-apart + `warn`-colored; grid stays the centerpiece. Entire panel draws through the L1 kit (chrome, buttons, tabs, grid cells, dividers) by palette role with hover on interactive elements; remaining GDI text retired; single `KitColor→LICE_pixel` boundary via the kit's `toLice` (now exposed in `draw_kit.h`). --- ## L4 — dock-panel button layout enhancement **Goal:** Re-home the `bank_panel`'s L2 button inventory around *frequency and intent* — three-zone structure: top toolbar (capture + placement + maintenance), bottom toolbar (Design View tagging + switching), footer (narrow mode toggle · Tail button · Prune). A layout re-home of buttons that fire existing actions; no new actions, no capture/placement behavior change, no touching the "capture ≠ placement" principle. Independent of L3. CONTEXT.md §Phase L (L4 dock-panel button layout). Product framing: `docs/product/visual-design-language.md` §L4. **Verify (in DAW):** the top toolbar fires every capture + placement + maintenance action; the bottom toolbar tags/untags selected tracks and switches/toggles Arrange/Design/show-both; the footer shows a narrow `[Arrange|Design]` toggle at the left, a Tail button that cycles tail on click with button states, and the Prune button set apart at the right in `warn`; every surface draws through the L1 kit in the DS-2 grey+pastel palette; capture and placement still never auto-insert (the buttons only fire the existing, unchanged actions). - [x] **Top toolbar**: capture cluster (capture item, capture track, batch items, batch razor, capture RT) + placement cluster (insert, insert-conform) + maintenance cluster (re-capture, cancel-realtime) moved from the L2 bottom bar to a top toolbar via `action_bar` row layout; buttons fire existing actions unchanged — no auto-insert. - [x] **Bottom toolbar**: Design View action family as buttons (tag / untag selected for mode, activate Arrange, activate Design, toggle active mode, show-both) via `action_bar` `ActionCluster::Tagging` + `ActionCluster::Switching`; fires existing registered Design View actions. - [x] **Footer toggle**: `[Arrange|Design]` segmented toggle shrunk to fit-its-text width and moved to the footer left of Prune; per-mode count as a compact adjacent label. - [x] **Footer Tail button**: Tail click-zone converted to a proper kit button (rest/hover/ pressed states; click still cycles the tail setting). - [x] **Footer order** (`[Arrange|Design]` · Tail · … · Prune `warn` set apart at the right); pure footer-strip layout in new `footer_bar` module covered by CTest (`footer_bar_tests`). **Notes/decisions:** - **Maintenance cluster restored to top toolbar** (Daniel's directive during L4 build): the initial L4 spec described the top toolbar as "capture + placement" only; the landed implementation includes re-capture and cancel-realtime in a Maintenance cluster on the same top toolbar. CONTEXT.md and CLAUDE.md updated to reflect the actual layout. - New pure module `src/footer_bar.{h,cpp}`: footer layout/hit-test (narrow mode toggle + Tail button + Prune); no LICE or REAPER types. New CTest target `footer_bar_tests`. - `action_bar` gained `ActionCluster::Tagging` and `ActionCluster::Switching` for the bottom-toolbar Design View verb groups. --- ## L5 — dock-panel button refinements **Goal:** Refine the L4 three-zone toolbar so the button faces read cleanly and group legibly — an overflow menu for the rare capture variants, short faces with full-name tooltips (no `ReaSampler:` prefix), an opposite-mode tag-button set, removal of the now- redundant Toggle and Activate-Arrange/Design buttons, and semantic-grouping spacing. **No new capture/placement behavior**; every button fires an existing registered action (the "capture ≠ placement" principle is untouched). Ungated by Phase S; sequences after L4. **Verify (in DAW):** the top bar shows only frequent capture/placement/maintenance buttons + a right-anchored More (⋯) menu that fires Batch Items / Batch Razor / Capture RT; hover tooltip shows the full action name with `ReaSampler:` prefix stripped; the bottom bar shows four Item/Track × Arrange/Design tag buttons with only the opposite-mode pair live (disabled pair visibly greyed via the kit `Disabled` state) and no Toggle button; cluster groups read as groups. All buttons fire the same actions their keybindings do. - [x] Top-toolbar overflow: Batch Items / Batch Razor / Capture RT pulled off the visible bar into a right-anchored **More (⋯) menu button** (kit-drawn button + `TrackPopupMenu` popup); each entry fires its existing command id. Pure layout owns the menu-button rect + hit-test (`overflow_menu` pure module); the popup + dispatch is shell. - [x] Short faces + drop `ReaSampler:` prefix on the button *face*; keep the keybinding micro sub-row. - [x] **Hover tooltip carrying the full action name (prefix stripped)** via a **custom LICE-kit hover-delay tooltip** (`tooltip` pure module): 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 keybinding sub-row still uses the live binding from `kbd_getTextFromCmd`. - [x] Bottom-toolbar four tag buttons: **Item: Arrange / Item: Design / Track: Arrange / Track: Design**, wired to the existing item-move (`VIEW_MOVE_ITEMS_ARRANGE` / `VIEW_MOVE_ITEMS_DESIGN`) + track-tag (`VIEW_TAG_ARRANGE` / `VIEW_TAG_DESIGN`) actions. - [x] **Opposite-mode enablement:** a button is live iff its target mode ≠ the active mode; otherwise drawn `Disabled` (kit disabled state, `TextDim`) and its click is a no-op. Pure predicate (`mode_enable` pure module) unit-tested; shell reads `view().activeModeId()` once per draw and applies. - [x] **Activate-Arrange / Activate-Design / Toggle buttons removed** from the bottom toolbar (all three actions stay registered; footer toggle owns mode switching). **Show Both** kept as a set-apart button on the bottom toolbar. - [x] Semantic-grouping spacing widened: `clusterGap` 16→24 (`buttonGap` remains 4; 6:1 ratio) on both toolbars so clusters read as groups. **Notes/decisions:** - **Activate-Arrange / Activate-Design / Toggle FORK resolved (Daniel):** all three removed from the bottom toolbar. The footer `[Arrange|Design]` toggle is the single mode-switch affordance; the bottom bar is tagging + Show Both only. - **Tooltip mechanism resolved as custom LICE-kit hover-delay tooltip** (DS-1 "keep drawing in the kit"): avoids attaching a SWELL tooltip control to non-child LICE rects. SWELL is the Win32-emulation layer for macOS/Linux and is not the mechanism used here; on Windows the path is native, and the chosen implementation is a custom kit-drawn tooltip. - New pure modules: `src/overflow_menu.{h,cpp}` (menu-button geometry/reserve/hit-test), `src/mode_enable.{h,cpp}` (opposite-mode enablement predicate), `src/tooltip.{h,cpp}` (placement + prefix-strip). New CTest targets `overflow_menu_tests`, `mode_enable_tests`, `tooltip_tests`. --- ## L6 — toolbar polish (in-DAW feedback refinement on L5) **Goal:** Polish pass on the L5 dock-panel state based on Daniel's in-DAW feedback — single-row button faces, keybinding surfaced in the hover tooltip, Cancel RT moved into the overflow menu, and visible top-bar cluster order tidied. No new modules, no new test targets, no capture/placement behavior change. - [x] **Single-row button faces:** keybinding micro sub-row removed from `ActionBarSlot` (and `bindingHeight`/`minSplitHeight` removed from `ActionBarSpec`); buttons now show only the short label. Toolbar height 40→28 px. - [x] **Keybinding in hover tooltip:** tooltip now renders "`phrase — binding`" when the action is bound, bare phrase when unbound (live binding via `kbd_getTextFromCmd`). - [x] **Cancel RT moved into overflow menu:** the More (⋯) popup now lists four entries — Batch Items / Batch Razor / Capture RT / Cancel RT. The visible top bar no longer has a Cancel RT button. - [x] **Visible top-bar cluster order:** Capture Item · Capture Track · Re-capture · Insert · Insert Conform (cluster order: Capture → Maintenance → Placement). **Notes/decisions:** - Icons were considered and deferred (not implemented in this pass).