Files
reasampler/COMPLETED.md
T
daniel e2bd4f4351 docs(phase-s): close out S-VIEW-10 — Phase S three-view editor redesign complete
Move S-VIEW-10 (velocity-curve editor UI) to COMPLETED; all ten S-VIEW points landed. No open S-VIEW items remain.
2026-07-27 16:11:12 -04:00

2444 lines
162 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 08 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:<mode>`-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 M0M11 capture roadmap and Phase D)
> **Separate phase namespace.** The M-numbers belong to the capture pillar
> (M0M11); 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 B1B5 (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 B1B4, 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).
---
## L7 — capture ordering, card metadata, and selection styling
**Goal:** Three grid-facing improvements to the dock panel, drawn through the L1 kit in the
settled DS-2 palette. (1) **Persisted deterministic capture order + drag-drop reorder + sparse
placement:** each bank (and the pool) carries an explicit, persisted per-sample order via a
per-`Bank` id→slot `SlotMap` in `bank_book`; a card may sit in a slot that leaves earlier slots
empty (gaps preserved; trailing empty tail trimmed for scroll extent). (2) **Decorative metadata
over the peaks:** each card overlays capture length as **bars.beats.subdivisions (bottom-left)**
and **seconds.ms (bottom-right)** in the kit's micro / value-mono type class, `text/dim`. (3)
**Selection restyle:** a selected card drops the inverted accent-fill and instead draws the
*normal* cell + an **`accent/tertiary` (pastel purple `#C2AAE8`) border**. No new capture /
placement behavior; the "capture ≠ placement" principle is untouched.
- [x] **`SlotMap` in `bank_book`:** gap-preserving per-`Bank` id→slot map — persisted
deterministic display order; interior gaps preserved / trailing tail trimmed; JSON rides
inside the existing `"banks"` blob; pre-L7 migration seeds dense insertion order via
`reconcileSlots()` on the load path. `bank_model` / `Sample` untouched by position (position
is a per-bank display concern).
- [x] **`BankBook` mutators:** `reorderSample` (insert-before-shift; same-slot = no-op),
`replaceSample` (occupant index-removal via the standard remove path + pool-guard inheritance;
no-op on reject), `orderedSampleIds`, `reconcileSlots`. All gap-preserving; deterministic;
CTest-covered.
- [x] **`Sample` meter stamp (ONE sanctioned `Sample` change):** `captureTimeSigNum` /
`captureTimeSigDenom` added to `Sample` + JSON round-trip; stamped on both capture paths and
refreshed on re-capture via `TimeMap_GetTimeSigAtTime` (API confirmed at build). Old samples
with no stamp fall back gracefully (blank musical read-out).
- [x] **`card_drag` pure module:** gesture precedence — leave-client → OS drag-out;
other-bank → move/copy; same-bank → reorder / Alt-over-occupied → replace. Cursor-cue map
returned as the pure resolved gesture. Sparse `computeSlotRects` / `hitTestSlot`. SWELL stock
cursors chosen at build: Reorder→`IDC_SIZEALL`, Move→`IDC_HAND`, Copy→`IDC_UPARROW`,
Replace→`IDC_SIZEWE`. New CTest target `card_drag_tests`.
- [x] **`card_meta` pure module:** bars.beats.subdivisions from the stamped tempo + meter;
seconds.milliseconds rounded. Both blank when the sample is unstamped. New CTest target
`card_meta_tests`.
- [x] **`bank_panel` sparse-grid render:** grid renders in sparse slot order; decorative
empty-gap cells drawn for unoccupied slots; every cell↔sample consumer remapped to
id-based occupied-ordinal space (selection, keyboard nav — arrows skip gaps, audition,
multi-select, delete / re-capture resolution, drags).
- [x] **`bank_panel` drop dispatch:** reorder / Alt-replace / move-copy drop with one-Ctrl-Z
undo via the existing batched undo pattern; per-slot drop highlight (`accent/hot`, doubled
outline for replace); SWELL stock cursor cues via `SetCursor` per the pure resolved gesture.
- [x] **`bank_panel` metadata overlay:** bars.beats bottom-left (Micro), s.ms bottom-right
(ValueMono), `TextDim`, decorative / non-interactive.
- [x] **Selection restyle:** normal cell + `accent/tertiary` purple border; inversion removed;
focus ring distinct from the selection border.
**Notes/decisions:**
- **M9 overlap (awareness note — unchanged intent).** M9 (capture-to-slot-N / insert-slot-N,
MIDI-bindable, MPC-style) remains **explicitly deferred (Daniel, 2026-07-26).** The
interchangeable-slot substrate L7 builds still eases a future M9 revival 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 (F2).
- **Build-time choices confirmed:** `TimeMap_GetTimeSigAtTime` confirmed at build for the
meter stamp; SWELL stock cursors chosen (no custom cursor load/synthesis required);
gap navigation = skip gaps (arrow keys skip empty slots); same-slot reorder = no-op.
- New CTest targets `card_drag_tests`, `card_meta_tests`.
---
## L3 — VST editor + embed-strip restyle
**Goal:** Bring the ReaSampler 9000 VST editor (`IPlugView` LICE surface,
`reasampler_editor.cpp`) and the S6 embed strip (`reasampler_embed.cpp`) up to the
settled-and-revised **B + three-accent pastel** look via the L1 kit: kit cached-font
`text()` (the kit's current face — no font change), kit component draws, the
**REAPER-grey neutrals** (`bg/base #2b2b2b` / `bg/panel #333333` / `bg/cell #3a3a3a`)
with the **three pastel accents** (primary lime / secondary teal / tertiary purple), the
**pastel spectral keyboard strip + zone bars** (active zone lifts to `accent/primary` +
a static glow) as the signature surface, and hover/pressed/drag interaction states
throughout. Merged as `c53683e`. **L3 was the last remaining Phase L point — Phase L
is now complete (L1, L2, L3, L4, L5, L6, L7 all landed).**
**Verify (in DAW):** the VST editor + embed strip render in the settled B + three-accent
pastel language through the L1 kit — kit AA cached-font text, gradient/rounded kit
components, the pastel spectral keyboard strip, working hover/pressed/drag; the VST3
class UID is unchanged (a visual refresh is not a compat event).
- [x] Route the VST editor's + embed strip's text through the kit's cached-font
`text()`; retire their raw GDI `DrawTextA` path (retired in both shells).
- [x] Retire the shells' **local pre-L1 palette** — the `kColBackground`/`kColCardBg`/
`kColThumb`/… forest-green-on-charcoal constants block in `reasampler_editor.cpp`
(and the mirrored constants in `reasampler_embed.cpp`) — and draw every surface
through the L1 `theme` roles. One kit, one look; two palettes collapsed to one.
- [x] Restyle the editor + embed components through the kit (capture-first browser
search/tabs/thumbnails, channel toggles, ADSR + pitch sliders, Varispeed/Preserve mode
toggles, zone bars, list rows, waveform + start/loop markers, segmented controls) in
the B (Neon Console) palette with hover/pressed/drag states throughout.
- [x] Apply **Direction C's pastel spectral treatment** to the keyboard strip + zone bars:
hue-mapped zones as a pastel sweep anchored on the three accents; active zone lifts to
`accent/primary` + a static glow (no animation); waveform + loop/start markers drawn
through `drawWaveform` + `warn`/accent marker roles; VST3 class UID unchanged.
**Notes/decisions:**
- **Full restyle, not a born-in-kit no-op.** The Phase S surfaces (`reasampler_editor.cpp`
+ `reasampler_embed.cpp`) arrived on dev drawing flat `LICE_FillRect` blocks + raw GDI
`DrawTextA`, off a local pre-L1 forest-green palette (`kColBackground` etc.) — the
coordination contract's "born in the kit" branch did not occur. L3 performed the full
restyle and reconciled the two palettes into one.
- **Shell-side only.** All `src/vst/` UI geometry modules (`editor_geometry`,
`keyboard_strip`, `waveform_view`, `capture_browser`, `param_slider`, `browser_scroll`,
`embed_strip`) are pure geometry/hit-test — zero LICE, zero draw. L3 touched only the
two draw shells; no geometry rework was needed.
- **Beta title band — textual-only distinction (Daniel, 2026-07-27).** The S18 beta
channel title band gets no distinct visual accent; L3 restyles it in the standard B
pastel palette and the beta-vs-stable distinction stays purely textual (the
channel-derived plugin name via `app_version`, as before). No channel-specific accent
color. Closes the one open fork from the L3 readiness review.
- **VST3 class UID unchanged.** A visual refresh is not a compat event; RT/`process`
path untouched; pure geometry modules remained pure throughout.
---
# Phase S — MIDI-playback instrument (native VST3 sampler; a second build artifact)
> **Landed on dev (merged 2026-07-27); DAW verification pending Daniel's smoke test.**
> S1S18 are all on dev. The cross-artifact ingest relay (one S13 bullet) was explicitly
> DEGRADED and remains deferred in `PLAN.md`. Authoritative spec: **CONTEXT.md
> §MIDI-playback instrument — additive phase spec (Phase S)**. Product framing:
> `docs/product/midi-playback.md`.
---
## S3 — pure sampler core (voice engine / envelope / keymap / repitch)
**Goal:** The REAPER-free **and** VST3-free sampler core — voice allocation/polyphony,
amplitude envelope (ADSR), key→sample and velocity→sample mapping (the keymap),
repitch/interpolation from root note, keymap resolution — unit-tested in CTest against
known signals. **The heart of the phase (D3); the mirror of
`bank_model`/`peaks`/`view_mode_model`/`bank_book`; test it hard.** The core is
invariant under the build-shape choice — no VST3 or REAPER type at its boundary.
CONTEXT.md §Phase S (pure core, module architecture).
**Verify:** CTest green. Voice allocation is correct under polyphony (note-on/off,
voice stealing where bounded); ADSR shape asserted against a known signal (mirror of
`peaks`); repitch from root note produces the expected pitch ratio; keymap resolution
maps a (note, velocity) to the correct sample/zone; the core takes and returns only
plain data (no VST3/REAPER types) — enforced by the test target linking neither SDK.
**Depends on:** S2 (consumes `rootNote` / loop points as core inputs).
- [x] Voice engine: polyphonic voice allocation (note-on/off, bounded voice stealing),
per-voice state, mono-and-basic-polyphony sufficient for Tier 0.
- [x] Amplitude envelope (ADSR) math — asserted against a known signal.
- [x] Repitch/interpolation from root note (chromatic pitch ratio across the
keyboard); loop-point-aware sustain for held notes.
- [x] Keymap model + resolution: key ranges/zones (Tier-1 shape) and the
(note, velocity) → sample/zone query; Tier-0 chromatic-from-single-root as the
degenerate case.
- [x] Tests: voice allocation under polyphony + stealing; ADSR envelope shape;
repitch pitch-ratio correctness; keymap resolution (single-root chromatic + zoned);
core boundary is plain-data-only (no VST3/REAPER types).
---
## S4 — Tier 0: "the bank plays" (single sample, chromatic)
**Goal:** The honest MVP — one bank sample mapped chromatically across the keyboard
from its root note, basic polyphony, a simple amp envelope, velocity→volume. Wire the
S3 core into the S1 VST3 shell over the live-state seam (bridge-read bank + audio via
the M4 project-relative path machinery). Editor deferrable behind a parameters-only
default view. CONTEXT.md §Phase S (Tier 0, seams). **Delivers the core promise.**
**Verify (in DAW):** on an instrument track, the VST3 plays a chosen bank sample
MIDI-triggered, repitched chromatically from its root note, with basic polyphony,
an amp envelope, and velocity→volume; it reads the live `"reasampler"` bank via the
bridge and resolves the WAV audio the same project-relative way `persist` does;
following the active project works; it never captures and never inserts into the
arrange (read-only over the bank).
**Depends on:** S1, S2, S3.
- [x] VST3 `process` marshalling: read MIDI note-on/off/velocity off the event bus,
drive the S3 core, write per-voice audio to the output bus. (Block-granular event
timing at Tier 0; sample-accurate offset scheduling is a later tier.)
- [x] Live-state seam: read the bank index + selected sample's root note from
`"reasampler"` ext-state via the bridge; resolve the WAV audio path the M4
project-relative way (shared convention with `persist`, not re-implemented — the
parent-of-.rpp derivation is extracted to `capture_paths::projectDirOfRpp`, which both
`persist` and the bridge call). Bank JSON parsed via the shared `bank_book` path (the
spike string-scan reader retired); ext-state key names shared via pure `ext_keys.h`.
- [x] Sample selection UI (minimal, in the `IPlugView` LICE editor): a clickable list
of the bank's samples; the pick is the instance's own VST3 component state
(setState/getState), never written back to the bank.
- [x] Tier-0 playback: chromatic-from-root, basic polyphony (16 voices), amp envelope,
velocity→volume — plays in REAPER's routing/record/render path like any VSTi. Sample
load / decode / keymap build happen off the audio thread and hand to `process` via a
lock-free atomic pointer swap (graveyard-reclaim); `process` never allocates.
---
## S5 — Tier 1: "a keymap" (zoned multisamples, per-sample root notes)
**Goal:** Multiple bank samples zoned across the keyboard (key ranges), each with its
own root note — a captured *kit* (one-shots) or a *multisampled instrument* (same
instrument sampled at several pitches) plays correctly. One sample per key-region.
CONTEXT.md §Phase S (Tier 1). **Where the root-note + key-range seam fields earn
their place.**
**Verify (in DAW):** a keymap of several bank samples plays correctly zoned across
the keyboard, each repitched from its own root note within its range; a captured kit
and a multisampled instrument both play as expected; the keymap is authored in the
instrument (performance map) while root notes come from the bank intrinsics (S2);
editing the keymap does not touch the bank.
**Depends on:** S4.
- [x] Keymap editor in the `IPlugView` LICE editor: assign bank samples to key ranges
(low/high note per sample), each with its own root note (from S2 intrinsics,
overridable in the performance map).
- [x] Tier-1 playback: zoned resolution — a note picks its zone's sample and repitches
from that sample's root note; one sample per key-region.
- [x] Performance-map persistence: the keymap (zones, per-sample assignment) is the
instrument's own state — held in the instrument as VST3 component state (setState/getState)
per D-B's data-ownership split; the live `"reasampler"` seam is read-only (bank +
intrinsics in, nothing written back), never written back as a bank intrinsic.
---
## S6 — embedded TCP/MCP UI (D-D — scheduled in-phase, after the editor)
**Goal:** Render a compact keymap/level strip **inline in the track/mixer control
panel** via `reaper_plugin_fx_embed.h` (`IReaperUIEmbedInterface`) — the same
Cockos surface REAPER's own embedded FX use — so the instrument draws inline, not only
in its own window. Composes with the S1/S5 LICE editor path (same LICE-class drawing).
**Scheduled, not deferred (D-D settled 2026-07-26):** a real later point, sequenced
last because it is polish over a Tier-0 need — but on the roadmap. CONTEXT.md §Phase S
(embedded UI, D-D).
**Verify (in DAW):** the instrument draws a compact inline strip in the TCP/MCP (not
only its own editor window); the inline surface reflects and (where offered) edits the
keymap/levels; the embed lifecycle is clean (open/close/resize); the same LICE drawing
as the main editor is reused.
**Depends on:** S5 (composes over the existing LICE editor). **Must-verify before
build:** the `IReaperUIEmbedInterface` contract + embed message/lifecycle against
`vendor/reaper-sdk/sdk/reaper_plugin_fx_embed.h`.
- [x] Implement `IReaperUIEmbedInterface` on the VST3; draw a compact keymap/level
strip inline in the TCP/MCP using the same LICE surface as the editor.
- [x] Embed lifecycle (open/close/resize/hit-test inline) handled cleanly; reflects
the live keymap/levels.
---
## S7 — stereo channel mode (mono | stereo; core channel dimension + bus negotiation)
**Goal:** Give the instrument a per-instance **channel-mode toggle — 1 (mono) or 2
(stereo)** — that "works with the REAPER audio bus automatically." Mono keeps today's
downmix path; stereo grows the S3 core a **channel dimension** (2-channel sample data,
per-voice stereo render, stereo interp/loop) and negotiates the VST3 output bus so
mono/stereo just works in REAPER's routing. **This is an S3-core extension, not a shell
hack** — it touches the engine Daniel smoke-tests, so it sequences first after the
editor/embed work. CONTEXT.md §Phase S (channel mode, D-E). **Decided direction
(2026-07-26); leans below are build-time residuals, not open forks.**
**Verify (in DAW):** an instance set to stereo plays a stereo capture in true stereo,
its VST3 output bus negotiated to 2 channels via `setBusArrangements` so REAPER routes it
without manual channel wiring; an instance set to mono plays the existing downmix path; a
mono source in stereo mode plays dual-mono (centered); a stereo source in mono mode
downmixes (existing policy); the mode is per-instance state that survives project
save/reopen (component state, like the selected sample); the pure core's stereo render is
asserted against a known two-channel signal (mirror of `peaks`), and mono behavior is
unchanged (regression).
**Depends on:** S3 (extends the core), S4 (extends the process/bus shell). Independent of
S8/S9.
- [x] Core channel dimension (pure, S3 extension): `SampleData` carries 1- or 2-channel
decoded PCM (`frames` + optional length-matched `framesR`; `channelCount()`);
`Voice::renderFrameStereo` + a `VoiceEngine::render(left,right,n)` overload produce a
per-channel frame sharing one read head + one envelope tick; stereo linear interpolation +
loop read per channel. Mono stays the degenerate case (`renderFrame` reads channel 0 only,
byte-identical). Tests: stereo render asserted against a known 2-channel signal; dual-mono;
per-channel repitch + additive mix; mono render unchanged (regression) — sampler_core_tests.
- [x] Channel-mode toggle as per-instance state: `ChannelMode {Mono,Stereo}` in the
instrument's own component state (v4 = v3 + a channel-mode byte; setState/getState);
default mono. Cross-mode policy in `decodeChannels`: **mono source + stereo mode →
dual-mono**; **stereo source + mono mode → downmix** (existing decode-side policy). The
toggle lives in the instrument, never written to the bank (D-B). v1/v2/v3 blobs lift to v4
with mono default; round-trip + lift tests — sample_map_tests.
- [x] Shell: `decodeRelative` fills 1- or 2-channel `DecodedZonePcm` per the active mode
(source channel count from the WAV layout); the process path renders the host's negotiated
output channel count (stereo into ch0/ch1, mono into ch0) — RT discipline unchanged.
- [x] VST3 bus negotiation: `setBusArrangements` accepts only the mode's arrangement
(kMono/kStereo), else rejects (kResultFalse) but keeps a valid mode arrangement so
`getBusArrangement` (base default) reports it; a runtime mode change repoints the output bus
+ calls `restartComponent(kIoChanged)` so REAPER re-negotiates. **Verified** against the
vendored Steinberg SDK (`ivstaudioprocessor.h` contract, `vstsinglecomponenteffect.cpp`
base impl, `ivsteditcontroller.h` kIoChanged); see handoff notes.
---
## S8 — ingest through the bank (one gesture: capture/import into bank + assign to instance)
**Goal:** Loading a sample into the sampler is **one gesture** — capture/import-into-bank
**and** auto-assign to the active sampler instance. **The extension owns ingest** (it has
arrange access, media-explorer access, and drop-target surface on its own panels); the
instrument stays a **read-only bank consumer**. This lives in the *extension* codebase
(actions + bank_panel + capture/insert), routing through the existing capture add-path and
the live `"reasampler"` seam the instrument already reads. CONTEXT.md §Phase S
(ingest-through-bank contract). **Decided direction "option 1" (2026-07-26).**
**Verify (in DAW):** a one-click "capture selected item / time-selection into the bank and
assign to the active instance" action captures via the existing capture path (never
auto-inserting into the arrange — load-bearing principle intact) and the target instance
plays the new sample on its next reload; a Media Explorer file imports into the bank and
assigns the same way; a file dropped onto a ReaSampler panel surface ingests into the bank
and assigns; the instrument never captures or imports (read-only over the bank throughout).
**Depends on:** S4 (an instance to assign to), M7 capture add-path, B2 (active-bank add
target). Best paired with S9 so assignment refreshes hands-free; functional without it
(assign triggers a reload on the target instance directly).
- [ ] "Capture selected item / time-selection into bank + assign to active instance"
action (`command_id`/`gaccel`/`hookcommand`, MIDI-bindable): reuse the existing capture
request path (`CountSelectedMediaItems`/`GetSelectedMediaItem` + `GetSet_LoopTimeRange`
as the capture inputs), add the resulting `Sample` to the active bank, then assign its
id to the target instance. **Never inserts a timeline item** (capture/placement stay
separate — the assignment is a bank-index + instance-selection act, not a placement).
- [ ] Media Explorer import → bank → assign: read the Media Explorer's current selection
via `MediaExplorerGetLastPlayedFileInfo` (path + selection range), import the file into
the bank (existing import/capture add-path), assign to the target instance. **Honest SDK
limit (verified against the vendored headers):** the Media-Explorer surface is thin —
`OpenMediaExplorer` (open/select) + `MediaExplorerGetLastPlayedFileInfo` (read the *one*
last-played/selected file + its range) 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 the user fires while a file is
selected in the ME), not a push/drop from inside the Media Explorer. **Spike:** confirm
`MediaExplorerGetLastPlayedFileInfo` returns 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: accept an OS file drop onto the docked
`bank_panel` (and its bank/tab regions) → ingest into the bank → assign. **Honest SDK
limit (verified):** REAPER exposes **no** drag-drop registration API; drop handling is on
ReaSampler's *own* HWNDs via SWELL/Win32 (`WM_DROPFILES` / an `IDropTarget` on the panel
HWND), the same surface the panel already owns. **Assess-and-flag (spike, do not promise
here):** a drop *onto the VST3 editor window* — whether the `IPlugView` HWND 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 to the extension over an agreed seam). Reported
honestly as a spike because it crosses the two-artifact boundary and the relay mechanism
is unproven; if it proves gnarly, drop-onto-panel is the shipped path and drop-onto-editor
is deferred.
- [ ] "Assign to instance" seam: how the ingest action names the target instance and hands
it the new sample id. Lean (build-time residual, not a fork): the active/last-focused
instance is the target, discovered via the host context the bridge already resolves; the
assignment is the same instance-owned selection state S4 already persists, so a reload
picks it up. If the change-detection seam (S9) exists, assignment refreshes hands-free;
without it, the ingest action pokes the target instance's reload directly.
---
## S9 — bank-generation change-detection (recapture / ingest refreshes instances hands-free)
**Goal:** Because instances reference sample **ids**, a **recapture** (M10) landing under
the same id — or an **ingest** (S8) touching the active bank — should refresh playing
instances **hands-free**, without the user re-opening each editor. Add a **bank-generation
counter** to `"reasampler"` ext-state that the extension bumps on any bank-content
mutation, and that the instrument polls off the audio thread on a safe cadence, calling its
existing `reloadFromBank()` when the generation changes. CONTEXT.md §Phase S
(bank-generation seam). **Closes the missing change-detection trigger the recapture
auto-update story needs.**
**Verify (in DAW):** a recapture that regenerates a sample already assigned to a live
instance refreshes that instance's playback within a bounded cadence, no editor re-open; an
ingest (S8) that updates the active bank likewise refreshes assigned instances; the poll
runs off the audio thread (never in `process`) and triggers the existing off-thread reload
path; instances not referencing a changed sample do not audibly glitch (reload is atomic —
the S4 graveyard-reclaim handoff); a project with no generation stamp (pre-S9) defaults
cleanly (treated as generation 0; first bump refreshes).
**Depends on:** S4 (the off-thread `reloadFromBank` + atomic handoff this drives). Writer
side is extension-only and independent of S8; consumed by S8 and M10 recapture. Best landed
alongside S8.
- [x] Writer (extension): a monotonic **bank-generation counter** stamped into
`"reasampler"` ext-state (new `ext_keys.h` constant — forever-stable spelling), 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.
- [x] Reader (instrument): poll the generation over the bridge on a safe **off-audio-thread
cadence** (a UI/timer tick, not `process`), compare to the last-seen value, and call the
existing `reloadFromBank()` on change — reusing S4's atomic pointer-swap handoff so a
refresh mid-play does not glitch. No new audio-thread work; no allocation in `process`.
- [x] Cadence + coalescing: pick a poll interval that is responsive but cheap (build-time
residual — a low-frequency UI timer, coalescing multiple bumps between polls into one
reload). **Must-verify before build:** that a bridge ext-state read on the instrument's
UI/timer thread is safe against a concurrent extension write (the read already tolerates a
stale value by design — it reloads on the *next* poll; confirm no torn-read hazard for the
single integer generation key).
---
## S10 — capture-first editor: browser + guided single-capture setup ("ReaSampler 9000" UX overhaul, part 1)
**Goal (REVISED 2026-07-26 — workflow-first reframe, Daniel):** Rebuild the editor's
default face around the **primary flow = one capture, fast**, not a keymap. A giant list of
"item" blocks is visually useless; most instances play a *single capture*, and zones are a
nice-to-have. So the default view is a **capture browser** (scannable cards with peak
thumbnails, name, root/key badge; **bank filter**) feeding a **guided single-capture setup**
(root note, play-mode basics, level) — and the keyboard strip serves the *single-capture*
case first (shows where the capture sits / its root). **Time-to-first-note is the metric.**
Multi-zone keymap editing is **demoted to an opt-in "Zones" panel** (S10-Z below), not the
default. The keyboard-strip drag machinery is still built here, but in service of the
capture-first layout. All layout/hit-test math is **pure geometry** (new `keyboard_strip` +
a `capture_browser` layout module — mirrors of `mode_switch`/`editor_geometry`); the LICE
draw + drag-state machine is the editor shell. RT discipline untouched (edits commit
off-thread via `commitMapAndReload`); the instrument stays a **read-only bank consumer**.
CONTEXT.md §Phase S (ReaSampler 9000 UX — capture-first editor).
**Policy reversal — fresh instance is SILENT, nothing auto-selected (was S4).** The S4
"first sample plays" fallback is **removed**: on open with no stored selection, the
instrument plays **nothing** and the editor shows a clear **empty state** ("pick a capture")
— it does not auto-play sample #1. Retires the `selectSample` first-sample fallback
(`sample_map.cpp` "No stored id → fall back to the FIRST sample") and the processor's
Tier-0 fallback that resolved it; an empty stored id now resolves to silence. A capture is
loaded when the user picks one (or via S13 drop-to-load / S8 ingest). This is a deliberate
reversal of the S4 convenience default, not a regression.
**Verify (in DAW):** a fresh instance plays **nothing** and shows the "pick a capture"
empty state (no auto-play of sample #1); the capture browser draws **peak thumbnails**
(the `Sample` peaks bank_model already carries — same data the dock panel thumbnails use),
name, and a root/key badge where present, and is **filterable by bank** (bank_book named
banks); picking a capture loads it, shows it (waveform/peaks + its root on the keyboard
strip), and it plays repitched from its root; time-to-first-note is a pick-then-play, not a
list-scroll; the keyboard strip shows the single capture's root and is draggable to set it;
the pure geometry modules are CTest-green (browser card/grid layout + hit-test; strip
edge-grab/body-move/key→note) with no host types at their boundary; the ±1 nudge-button row
is gone.
**Depends on:** S4 (the selection state + reload path this reverses the fallback on), S1
(the LICE `IPlugView` drag/event routing — extends the click-only `wndProc` to
`WM_MOUSEMOVE`/`WM_LBUTTONUP`), S5 (the `PerformanceMap`/zone model the opt-in Zones panel
edits — but the default face does not require a keymap). **Adopts the Phase L kit when
available — not gated on Phase L.** S10 builds its browser cards + keyboard strip with the
current LICE drawing; when Phase L's L1 kit lands on `dev`, this surface adopts it (the one
source of drawing). The drag machine's `WM_MOUSEMOVE` tracking also lights the kit's
**hover** states at near-zero marginal cost once the kit is present.
- [ ] No-auto-select + empty state (the policy reversal): remove the `selectSample`
first-sample fallback (`sample_map.cpp`) and the processor's Tier-0 fallback that
consumed it — an empty stored selection resolves to **silence**, not sample #1. The
editor draws a clear **empty state** ("pick a capture" affordance) when nothing is
selected. Pure change is testable (empty id → `nullopt`); the empty-state draw is shell.
- [ ] Capture browser (pure layout + shell draw): grow `SampleChoice` to carry the
**peak thumbnail data** (from the `Sample` peaks bank_model already stores — the same
peaks the dock panel draws), the **root/key badge** (S2 `rootNote` intrinsic / the
optional musical key), and its bank. A new pure `capture_browser` module lays out
scannable **cards/rows** (card rect grid, thumbnail rect, hit-test a point → card) — no
host types at the boundary, unit-tested. The shell draws each card's peak thumbnail +
name + badge in LICE (house palette) and routes a click to select.
- [ ] Bank filter (pure + shell): a filter/tab strip over the browser that narrows the
drawn cards to a chosen bank_book bank (or "all"). Filter-tab layout + hit-test pure
(mirror of `mode_switch`); the active-filter state is transient UI state; the shell draws
the tabs and applies the filter to the card list. (Type-to-filter search folds in from
S12 — see S12's boundary note; a name-substring filter over the same card list.)
- [ ] Guided single-capture setup (the fast path): once a capture is picked, a prominent,
self-explanatory setup surface — **root note** (settable on the keyboard strip / typed),
**play-mode basics**, **level** — sized for the single-capture case, not a zone table.
Graphic and descriptive; the point is to get from pick → set → play with no hunting.
- [ ] Pure `keyboard_strip` geometry module (serves the single-capture case first): map a
MIDI key span across a strip width (128 keys → pixels, reusing the S6 `embed_strip`
key-span idiom); a **root marker** for the loaded capture; `pixel→note` and a `keyAtPoint`
for click-to-set-root; a drag-delta resolver `(grabbedField, startNote, dxPixels) →
newNote`; **per-zone bar rect** + **edge-grab hit regions** (resize handles vs. body
move-handle) for the opt-in Zones panel. No VST3/REAPER/LICE types at the boundary;
unit-tested (root marker, edge grabs, body-move delta, key mapping, clamps low≤high,
boundary rounding). Mirror of `mode_switch`/`editor_geometry`.
- [ ] Editor shell drag-state machine: `WM_LBUTTONDOWN` grabs a card / a key / a zone
edge-or-body, `WM_MOUSEMOVE` updates the in-flight edit against the pure resolver,
`WM_LBUTTONUP` commits via the existing `commitMapAndReload` (off-thread reload; RT path
untouched). Live visual feedback while dragging; a single undo-coherent edit on release.
### S10-Z — Zones panel (opt-in multi-zone keymap editing; demoted from the default face)
The multi-zone keymap editor is now an **opt-in view/panel** ("Zones" toggle), not the
default. It reuses the same `keyboard_strip` geometry and drag-state machine: each zone a
bar over the keys it covers; **drag an edge** → low/high note; **drag the bar body** → move
the zone (span preserved); **click a key** → set/relocate the zone's root. This is the
capability RS5K structurally lacks (multi-zone in one instrument), kept as a *nice-to-have*
per Daniel's hierarchy — "most of the time the zones won't be used." Add/select/delete a
zone; overlapping zones render legibly and resolve first-match. The seven ±1 nudge/delete
mini-buttons are retired everywhere; delete is one affordance (a small × on the bar or a
keystroke). The `zoneHitTest`/±1 nudge path in `editor_geometry` is retired (a numeric
fallback for accessibility is a build-time residual, not a fork).
**Verify (in DAW):** the Zones panel is reachable via an explicit toggle (default view is
the capture browser + single-capture setup, not this); a zone's range is set by **dragging
edges** (not nudge clicks); body-drag moves the span; click-a-key sets the root (audible on
the next held note); zone add/select/delete work; the ±1 nudge row is gone.
- [ ] "Zones" panel toggle (opt-in): the default editor face is the capture browser +
single-capture setup; a toggle reveals the multi-zone keymap editor. Toggle state is
transient UI state (or per-instance component state if it should persist — build-time
residual).
- [ ] Zone edit via the shared strip: draw the keyboard strip + zone bars in LICE, drive
the shared drag-state machine (edge = resize, body = move, key = root), commit via
`commitMapAndReload`. Zone add/select/delete as single affordances; ±1 nudge row gone.
---
## S11 — waveform view with draggable loop points (UX overhaul, part 2)
**Goal:** Give each sample/zone a **waveform display** with **draggable start/end/loop
markers** — the S2 loop-point intrinsics and the S5 performance map already carry the data;
today there is no way to *see* a sample or *set* its loop by eye. Selecting a zone (or a
bank sample) shows its waveform (peaks via the existing `peaks` module, fed the decoded
PCM the shell already loads); drag the **loop-start / loop-end** markers to set the sustain
loop, snapping to zero-crossings (the S2 spec's zero-crossing-aware requirement). Loop
points are a **performance-map override on the zone** where set, seeded from the bank
intrinsic (D-B split: the bank carries the file-fact default; the instrument's drag is the
performance choice). All marker/waveform layout + hit-test is pure geometry; peaks compute
reuses `peaks`; the draw + drag is the shell. CONTEXT.md §Phase S (ReaSampler 9000 UX —
waveform view).
**Verify (in DAW):** selecting a zone shows its sample's waveform; dragging the loop-start
and loop-end markers sets the sustain loop and a held note audibly loops that region;
markers snap to the nearest zero-crossing; a sample with no loop shows the "no loop" state
and a held note past the end goes silent (existing core behavior); the waveform peaks match
the audio (mirror of the `peaks` envelope assertion); the marker geometry module is
CTest-green (px↔frame mapping, marker grab regions, clamp start≤end).
**Depends on:** S2 (loop-point intrinsics), S3 (loop-aware sustain the markers drive), S5
(the zone the loop attaches to), S10 (shares the editor's drag-state machine + shell). The
zero-crossing snap is a small pure helper over the decoded PCM.
> **Boundary note (S10 reframe, 2026-07-26):** the waveform view is now **central to the
> single-capture fast path**, not just per-zone. Selecting a capture in S10's browser shows
> its waveform (this is "see it" in pick → see it → play it); the loop-marker drag here
> extends that same waveform surface. S11's waveform draw is the same one S10's picked-
> capture view uses — build it once, S10 shows it read-only for the single capture, S11 adds
> the draggable loop markers. No renumber; S11 stays the loop-editing point.
- [ ] Pure waveform/marker geometry: `frame↔pixel` mapping across the waveform rect, marker
x-position from a frame index, marker grab regions (start/end/loop-start/loop-end),
drag-delta `(grabbedMarker, dxPixels) → newFrame` with clamps (start≤end, in-bounds). A
**zero-crossing snap** helper: nearest sign-change frame to a target (pure, over the
decoded mono PCM). No host types; unit-tested.
- [ ] Waveform draw: compute peaks with the existing `peaks` module from the shell's already-
decoded PCM (no new decode path, no new WAV reader); draw the envelope in LICE in the
house style; draw the loop markers over it. Reuses the S10 drag-state machine.
- [ ] Loop-point edit → performance-map override: a dragged loop writes a per-zone loop
override (seeded from the S2 bank intrinsic, D-B), committed off-thread via
`commitMapAndReload`; the bank intrinsic is never written back (instrument is a read-only
bank consumer). Extends `PerformanceZone` with an optional loop override (additive, same
shape as `rootOverride`) + its component-state (de)serialize (version bump, back-compat
with S5's v2 map blob — a truncated/older blob defaults the override absent).
---
## S12 — editor scale + ergonomics (UX overhaul, part 3; scrollable/searchable list, direct entry)
**Goal:** Make the editor usable **at bank scale** and close the remaining RS5K-parity
gaps: the sample list **scrolls** (today a long bank's rows run off the panel with no way
to reach them) and has a **type-to-filter search**; add **direct numeric entry** for a
zone's low/high/root (a click-to-type field over the strip, for precision the drag can't
hit) and an **ADSR control** for the amp envelope (S3 already has the ADSR math; today it
is fixed — expose attack/decay/sustain/release as draggable sliders, per-instance state).
This is the "sensible list handling + direct manipulation of the parameters that exist"
tier. All slider/scroll/search-box layout + hit-test is pure geometry; the shell draws +
routes; ADSR/scroll/filter state is instrument-owned (component state / transient UI
state). CONTEXT.md §Phase S (ReaSampler 9000 UX — scale + ergonomics).
**Verify (in DAW):** a bank with more samples than fit **scrolls** (wheel + drag) and every
sample is reachable; typing filters the list to matching names; a zone's low/high/root can
be **typed** (not only dragged) via a click-to-edit field; the amp envelope's ADSR is
**adjustable** (four draggable controls) and the change is audible + persists across project
save/reopen (component state); the scroll/search/slider geometry is CTest-green.
**Depends on:** S10 (the editor shell + drag-state machine + the **capture browser** the
scroll/search now apply to), S3 (the `AdsrParams` the ADSR sliders drive — already wired
into the voice engine; today they are fixed defaults), S5 (the map the numeric fields edit).
> **Boundary note (S10 reframe, 2026-07-26):** the "sample list" S12 originally scrolled and
> searched **is now S10's capture browser** (cards with peak thumbnails, bank filter). What
> pulled INTO S10: the browser layout itself, the peak thumbnails, and the **bank filter**
> (a bank_book tab, distinct from name search). What stays in S12 and applies **to S10's
> browser**: (a) **scroll** for a bank longer than the panel, and (b) **type-to-filter
> search** (a name-substring narrow over the same cards, composing with S10's bank filter —
> bank filter picks the bank, search narrows within it). The scroll/search geometry is pure,
> layered over the `capture_browser` module S10 builds. Net: S12 = scroll + search over the
> S10 browser + numeric entry + ADSR; the browser *card* work is S10's. **S12 now also
> carries the S15/S16 control surfaces** (per-zone Gate/Trigger mode toggle, AHDSR hold
> control, Trigger %-length/fade controls, Varispeed/Preserve engine toggle, and the AD pitch
> envelope depth/shape controls) — deferred here from S15 and S16 per spec.
- [x] Scrollable, searchable capture browser: a scroll offset (wheel + scrollbar drag) so a
bank longer than the panel is fully reachable; a **type-to-filter search** that narrows
the drawn cards to matching display names, **composing with S10's bank filter** (bank
filter selects the bank; search narrows within it). Scroll/search layout + hit-test is
pure geometry (visible-card window, scrollbar thumb rect, search-box rect), layered over
S10's `capture_browser` module; filter/scroll state is transient UI state. **Landed:**
pure `browser_scroll` module (`browser_scroll_tests`) — scrollContentHeight / max / clamp,
visibleCardRange window, scrolledCardCellRect, scrollThumbRect + thumbDragToOffset inverse,
searchBoxRect, nameMatchesQuery + filterNameIndices. Editor shell wires wheel (`WM_MOUSEWHEEL`),
thumb-drag (`DragKind::kScrollThumb`), and the search box (`WM_CHAR` -> `onSearchChar`,
composed into `rebuildVisible`). Scroll/search are transient (never persisted).
- [x] Direct numeric entry for zone low/high/root: a click-to-edit field over the strip
(LICE text-entry idiom) so a precise note can be typed, not only dragged. Commits via
`commitAndReload` like every other edit. **Landed:** pure `note_entry` module
(`note_entry_tests`) — `parseNoteEntry` accepts a decimal integer OR a note name (C4==60),
clamps to [0,127], rejects garbage. Editor shell hosts three focusable fields (low/high/root)
on the Zones legend, committing on Enter through `commitAndReload`.
- [x] ADSR/AHDSR editor + S15/S16 control surfaces: draggable sliders over the S3 `AdsrParams`
(attack/**hold**/decay/sustain/release — hold is the S15 addition) plus the deferred S15/S16
controls — per-zone Gate|Trigger mode toggle, Trigger %-length/fade-in/fade-out, Varispeed|
Preserve engine toggle, and the AD pitch-envelope enable/attack/decay/±semitone depth. All
edit the SELECTED zone's `ZonePlayParams` (instrument-owned, D-B; never the bank), round-trip
through the existing v3 component-state blob (no new persistence — the S15/S16 payload already
landed in the core pass), and commit off-thread via `commitAndReload`. **Landed:** pure
`param_slider` module (`param_slider_tests`) — control-panel stack layout, toggle-segment
split + hit-test, slider value<->pixel round-trip + clamping, point->control routing. The
shell owns the control-id -> engine-param binding + the value DOMAIN mapping (frames/fraction/
semitones); the module stays engine-free.
**Notes/decisions:**
- **Wall-clock envelope times stored as rate-free SECONDS (zones payload v5)**, resolved to
frames at keymap build against the live project rate — no hardcoded sample rates anywhere in
`src/`. Daniel's standing ruling; enforced throughout the voice engine and verified at S12.
---
## S13 — drop-to-load (partial landing; relay deferred)
**Goal:** Make "load a sample into the sampler" **one gesture from the editor** via an
OS file drop onto the editor window. The cross-artifact relay was a spike — the
instrument's REAPER bridge (`reaper_bridge`) is deliberately READ-ONLY; the relay is
DEGRADED and deferred. The two landed items are the drop-accept surface and the UX degrade
path. The deferred relay item remains in `PLAN.md`. CONTEXT.md §Phase S (drop-to-load).
**Spike verdict (ps-w12, 2026-07-27): DEGRADED.** The relay would require (a) a new
instrument WRITE seam into ext-state and (b) an extension-side timer poller + claim/clear
nonce — the same cross-process handshake race the S17 spec rejected. The shipped ingest
gesture stays drop-onto-docked-panel (S8). The relay is a future wave when the design is
ready.
- [x] Editor-window drop target: accept `WM_DROPFILES`/`IDropTarget` on the editor child
HWND (the same SWELL/Win32 surface `bank_panel` owns), extracting the dropped file
path(s). Windows-only (D5). This is the *acceptance* half; the ingest is the extension's.
**Landed:** the editor child window calls `DragAcceptFiles(TRUE)` on attach and handles
`WM_DROPFILES` (`reasampler_editor.cpp`). Windows-only (D5).
- [ ] Cross-artifact ingest relay (the S8-flagged spike): **DEFERRED** — relay mechanism
proved load-bearing to redesign. Remains in `PLAN.md` §S13.
- [x] UX degrade path: when the relay is unavailable/unproven, the editor shows a clear
"drop files on the ReaSampler panel to add" affordance rather than silently swallowing the
drop — the shipped ingest gesture stays discoverable either way. **Landed:** the editor
ACCEPTS the drop and flashes a transient banner ("drop files onto the ReaSampler bank panel
to add them") that decays over a few sync ticks, plus a persistent affordance line in the
empty state ("drop a file onto the ReaSampler bank panel"). No file is ingested; NO timeline
item is ever inserted (the hard invariant — the editor only displays guidance).
---
## S15 — sampling modes: Trigger vs Gate (per-sample play-mode; core + editor)
**Goal:** Give each played sample a **play mode** — **Gate** (classic held note) or
**Trigger** (one-shot) — a per-sample/per-zone performance choice (D-B, instrument-owned).
**Gate** is today's behavior grown from ADSR to **AHDSR** (adds a Hold stage): note-on →
attack/hold/decay/sustain, note-off → release, sustain **loop points apply** (S11's
draggable loop UI is Gate-mode UI). **Trigger** is a one-shot drum-pad: note-on fires
playback of a defined **% of sample length** with a **fade-in** and **fade-out** ramp,
**ignores note-off**, and uses **no sustain loop**. **Both** modes carry a **modifiable
start point** (playback begins at an offset into the sample, not always frame 0). This is
an **S3-core extension** (the engine Daniel smoke-tests) plus editor surfacing — the mode +
its parameters are instrument performance-map state, never a bank fact. CONTEXT.md §Phase S
(Sampling modes — Trigger vs Gate). **Daniel's feature set is settled.**
**Verify (in DAW):** a sample in **Gate** mode plays held with the AHDSR envelope (hold
stage audible between attack and decay), releases on note-off, and loops its sustain region
if loop points are set; a sample in **Trigger** mode fires a fixed % of its length on
note-on with audible fade-in/out, **plays through to completion regardless of note-off**,
and never sustain-loops; the **start point** offsets playback in both modes (a note starts
partway into the sample); the mode + parameters are per-instance component state that
survive save/reopen; the pure core's Trigger envelope (fade-in → hold → fade-out over
%-length frames) and the AHDSR hold stage are asserted against known signals; existing
Gate/ADSR behavior is unchanged when hold=0 (regression).
**Depends on:** S3 (extends the envelope + voice read-position machinery), S5 (the
`PerformanceZone` the mode + params attach to), S11 (Gate loop-point UI; Trigger's waveform
shows start + %-length + fades on the same waveform surface). Independent of S7.
- [x] Core: `PlayMode { Gate, Trigger }` on the voice + the envelope split. **Gate** grows
`AdsrParams` → `AhdsrParams` (add `holdFrames` between attack and decay; hold=0 is the
exact current ADSR — back-compat). **Trigger** is a distinct envelope: play `[start,
start + lengthFraction·(framesstart))` with a **fade-in** ramp (0→1 over `fadeInFrames`)
and a **fade-out** ramp (1→0 over `fadeOutFrames` ending at the play-length end),
**ignoring note-off** (release is a no-op in Trigger). Fade curve default **equal-power**
(constant-power `sin`/`cos`, click-free on one-shots); pure, unit-tested against a known
signal.
- [x] Core: **modifiable start point** — the voice's initial `readPos_` is `startFrame`
(frame offset), applied in both modes; the existing per-frame `readPos_ += ratio_` read
and loop/interp machinery is otherwise unchanged. Clamp `0 ≤ startFrame < frames`.
- [x] Core: **% length → frames + fade mapping** for Trigger. `lengthFraction ∈ (0,1]`
resolves to `playEnd = start + round(lengthFraction·(frames start))`; `fadeInFrames` /
`fadeOutFrames` clamp so their sum ≤ play length (fade-out anchored to `playEnd`). Note-off
in Trigger does nothing; the voice frees when `readPos_ ≥ playEnd`. **Choke on note-off is
NOT in scope** (fork S15-F1, held).
- [x] Parameter ownership (per-sample/per-zone, instrument-owned): the play mode + its
params (Gate: AHDSR; Trigger: %-length, fade-in, fade-out; both: start point) attach to
the **capture selection / zone**, stored in the **performance map** (D-B). Additive/
version-bumped component state; back-compat — a truncated/older blob defaults to **Gate**,
hold=0, start=0, no fades = exactly today's behavior.
- [x] Editor (S11 waveform surface, mode-aware): **Gate** shows draggable **start + loop
markers** (S11's loop UI); **Trigger** shows **start + %-length end + fade-in/out**
handles on the same waveform. A **mode toggle** per capture/zone in the guided setup
(S10) / Zones panel (S10-Z). Marker/handle geometry is pure; commits off-thread via
`commitMapAndReload`. The instrument stays a **read-only bank consumer**. **Editor control
surface deferred to S12 tier (spec-sanctioned).**
---
## S16 — pitch engine modes (Varispeed vs Preserve) + pitch envelope (per-voice)
**Goal:** Give the sampler **two pitch behaviors** and a pitch envelope that rides whichever
is chosen. **Varispeed** — resampling that couples pitch and duration (classic sampler /
RS5K default). **Preserve** — duration-preserving repitch, where a transposed note keeps
its original length. A **per-zone/per-capture pitch-engine mode**. On top of either engine
rides a per-voice **AD pitch envelope**, **off by default** — a short attack-decay pitch
modulation. Per-instance performance-map state (D-B). CONTEXT.md §Phase S (Pitch engine
modes + pitch envelope).
**Verify (in DAW):** Varispeed — a note an octave up plays half as long as the root note;
Preserve — a note an octave up plays at the same duration as the root note; pitch envelope
off (default) under either engine: no pitch modulation applied (regression); pitch envelope
on: an AD envelope makes a note start offset in pitch and glide to the zone's base pitch
over attack+decay; CPU stays within budget at polyphony cap.
**Depends on:** S3, S5, S15. Independent of S7.
- [x] Core: **pitch-engine mode on the voice/zone** — `PitchEngine { Varispeed, Preserve }`.
**Varispeed** = `readPos_ += ratio_` (today's path, pitch and duration coupled). **Preserve**
= duration-preserving: the read advances at the **source** rate while a pitch shifter
transposes the output. Mode is per-`PerformanceZone` performance state (D-B), additive/
version-bumped; absent/older blob → engine default. Pure where possible: Varispeed math and
duration-invariance contract unit-tested.
- [x] Core: **Preserve engine implementation** — hand-rolled pure OLA `pitch_shift` module
(house pattern — CTest-testable, no WDL/REAPER/VST3 type at the boundary). Pre-allocated,
no locks, no `process` allocation; pre-warmed at voice allocation. `pitch_shift_tests`
CTest target. WDL_SimplePitchShifter excluded by include-chain (windows.h); held as a
quality/latency swap alternative (`PitchEngine::Preserve` contract identical behind the seam).
- [x] Core: a per-voice **AD pitch envelope**, engine-aware — `PitchEnvParams { enabled=false,
int64 attackFrames, int64 decayFrames, double peakSemitones }`. Off by default (`enabled=false`
→ offset always 0). Under **Varispeed** the offset multiplies `ratio_`; under **Preserve**
the offset is **added to the shifter's shift amount**. Pure, unit-tested.
- [x] Parameter ownership + editor: pitch-engine mode + pitch envelope are per-zone
instrument performance-map state (D-B), additive/version-bumped. Editor exposure deferred
to S12 tier (spec-sanctioned). The instrument stays a **read-only bank consumer**.
**Notes/decisions:**
- **S16-F1 (default engine):** default is **Preserve** (Daniel's directive: "I want
duration-preserving repitching"). Per-zone toggle prominent and cheap to flip for
drum/one-shot zones that want Varispeed character.
- **S16-F2 (Preserve engine):** hand-rolled pure OLA `pitch_shift` module chosen over
`WDL_SimplePitchShifter` (excluded by include-chain). Both share the same
`PitchEngine::Preserve` contract; `WDL_SimplePitchShifter` is held as the quality/latency
swap (the HELD item in `PLAN.md`).
---
## S17 — drop-and-load: drag a capture onto a track's FX button → instantiate ReaSampler 9000 with the capture loaded
**Goal:** Turn a bank capture into a playable instrument in one gesture. While a capture
is dragged from the `bank_panel`, a track's TCP **FX button** lights as a drop zone,
and dropping there instantiates a **ReaSampler 9000** on that track with the dragged
capture **already loaded and selected** for playback. A third `DragGesture` —
`InstrumentDrop` — added to the `drag_out` pure module. Mechanism: `TrackFX_AddByName` +
VST3 component-state injection via `TrackFX_SetNamedConfigParm(..., "vst_chunk", blob)`.
The shared `instrument_drop` pure module constructs the blob via the instrument's own
`sample_map::serializeComponentState`/`deserializeComponentState` — one serializer called
from both artifacts, so the blob format cannot drift. CONTEXT.md §Phase S (drop-and-load).
**Verify (in DAW):** dragging a single capture from the dock over a track's FX button
highlights it; dropping instantiates ReaSampler 9000 on that track with the dragged
capture loaded, selected, and MIDI-playable immediately; OS drag-out to Explorer still works
unchanged; internal bank-to-bank drag still works unchanged; no media item ever inserted
into the arrange. DAW-verify: whether REAPER's `vst_chunk` write-parm expects the plugin's
raw IComponent-state bytes or wraps them in a REAPER container header.
**Depends on:** M11 (`drag_out` gesture machinery), Phase S S4, the load-capture seam
(VST3 component-state injection — the shared blob contract).
- [x] Extend the `drag_out` pure module with the third gesture: `DragGesture::InstrumentDrop`
when a drag armed with a **single** capture is over REAPER's UI outside the panel client
rect; `OsDrag` only when it has left REAPER entirely; `Internal`/`OsDrag`/`None` otherwise
unchanged. `DragState` gains two defaulted fields (`singleCapture`, `overReaperUi`); M11
callers filling only `{dragging, hasArmedSamples}` get byte-identical M11 behavior — the
existing tests are the non-regression proof. **OPEN QUESTION RESOLVED (multi-capture over
FX button): REJECT** — only `singleCapture` arms InstrumentDrop; multi-payload over
REAPER UI falls through to `OsDrag`.
- [x] Shell (extension): hover-track the pointer over REAPER's UI during the drag; resolve
the hovered track + its FX button via `GetThingFromPoint` (verified present; its info string
reports `"fx_chain"`/`"fx_N"` for the FX region); highlight it as a drop target; on release
drive the drop. FX HOTSPOT resolved — drop target is the FX region (info prefix `"fx_"`),
not home-grown geometry. `instrument_drop_win::resolveFxDropTarget` implements this.
**Landed in `src/instrument_drop_win.cpp`.**
- [x] Shell (extension): on drop, `TrackFX_AddByName(track, "VST3:" + app_version::vstPluginName(),
false, negative)` to always add a fresh instance; capture the returned FX index; invoke the
load-capture seam. Batched into one REAPER undo point (`Undo_BeginBlock2`/`EndBlock2`).
NEVER inserts a timeline item. **Landed: `instrument_drop_win::performInstrumentDrop`.**
- [x] **ReaSampler 9000 load-capture seam:** the instrument's existing `setState`/`getState`
already round-trip the full `ComponentState` via
`sample_map::serializeComponentState`/`deserializeComponentState` (S10). The extension
REUSES that exact serializer through the new pure `instrument_drop` module
(`buildInstrumentDropChunk` → `serializeComponentState` → base64). The shared-writer
requirement is met STRUCTURALLY — the blob format cannot drift. Pure round-trip test decodes
back through the instrument's own reader and asserts the capture is selected.
**`instrument_drop` lives at `src/instrument_drop.{h,cpp}`** (extension-side pure module);
CTest target `instrument_drop_tests`.
- [x] Tests: gesture disambiguation (inside-panel / over-REAPER-UI / left-REAPER) across
single- and multi-capture payloads; M11 OS drag-out and internal bank-to-bank drag both
unchanged. `test_drag_out.cpp` adds the InstrumentDrop cases with M11 cases retained as
the non-regression guard; `test_instrument_drop.cpp` is the blob round-trip + base64 codec
coverage. FX hit resolution is REAPER-API-bound (`GetThingFromPoint`) — DAW-verified in the
shell, not pure-tested (noted honestly).
**Notes/decisions:**
- `resetDragState()` consolidation in `bank_panel` (prior drift cleaned up this wave).
- Copy-only / no-auto-insert / no-source-deletion invariants all hold: NEVER inserts a
timeline item; the bank file is not deleted; prune remains the sole file-deleter.
---
## S18 — VST3 channel isolation: a beta ReaSampler 9000 that pairs with the beta extension only
**Goal:** Extend Phase V's beta/stable channel split (V4) to the **ReaSampler 9000 VST3
instrument**, so a beta-built VST is a distinct plugin that pairs only with the beta
extension, and a stable VST pairs only with stable — installable side-by-side in one REAPER
with no collision. **One channel per binary; all identity derives from the ONE
`REASAMPLER_CHANNEL_IS_BETA` bit via `app_version`, no scattered `#ifdef`s.** Mirrors V4's
philosophy exactly. Two forever-stable VST3 class UIDs committed (the existing stable UID +
a new beta UID). CONTEXT.md §Phase S (VST3 channel identity — the UID-pair invariant).
**Verify (in DAW):** stable VST3 (`reasampler_9000.vst3`) and beta VST3
(`reasampler_9000_beta.vst3`) install side-by-side in one REAPER as distinct plugins;
a beta instance reads only the beta extension's banks; a stable instance reads only stable's;
save/reopen rebinds by the correct UID; nothing plays differently (identity/pairing wave only).
**Depends on:** V4 (`app_version` channel-identity single-source), S1 (VST3 factory identity).
- [x] **Beta VST3 class UID (the permanent commitment).** Second FOREVER-STABLE class UID
(`REASAMPLER_PROC_UID_BETA_1..4` + `kReaSamplerProcessorUIDBeta`) alongside the existing
stable UID in `reasampler_vst.h`. The channel bit selects which UID the factory registers
(`DEF_CLASS2`) — compile-time, one class per binary. Both UIDs frozen forever.
- [x] **Channel-derived binary + display identity (no scattered `#ifdef`s).** Binary name:
CMake VST3 target `OUTPUT_NAME` forks by channel — `reasampler_9000` (stable) /
`reasampler_9000_beta` (beta) — via `REASAMPLER_VST_OUTPUT_NAME`. Display name: factory
`DEF_CLASS2` plug-in display string sourced from `app_version::vstPluginName()` — "ReaSampler
9000" / "ReaSampler 9000 beta". Editor title band + S6 embed-strip label channel-aware from
the same accessor.
- [x] **Factory vendor/version strings channel-aware** where V4 does the equivalent. Version
display carries the `-beta` render (`appVersion()` yields `"0.9.01-beta"` on beta).
- [x] **Pairing-surface invariant recorded** (no new code — a documented guarantee): channel
isolation is structural — all wire keys live under the channel-derived `kProjExtNamespace()`,
so a future wire key that forgets to isolate is impossible by construction. The invariant
is recorded in CONTEXT.md.
- [x] **DAW-verify contract (the acceptance gate, no unit test — identity is a shell fact).**
Both channels installed side-by-side: each browser sees only its channel's banks; a
project saved with a beta instance reopens rebinding to the beta VST and restores its state.
---
## S-VIEW-BUG-1 — drop-to-FX bug fix (Wave 1)
**Goal:** Dropping a capture onto a track's FX chain (TCP FX button or FX-chain window) must
instantiate + init ReaSampler 9000, not fall through to arrange-as-audio.
**Root cause (diagnosed and fixed in Wave 1):** the FX-hotspot classifier in the pure
`instrument_drop` module only matched FX-chain and floating-window hotspot strings (`fx_*`);
a drop on the TCP FX button (`tcp.fx`) or MCP FX button (`mcp.fx`) was not matched and fell
through to `OsDrag` (arrange-as-audio).
**Fix:** widened then narrowed the predicate in `instrument_drop` to match `fx_*` / `tcp.fx` /
`mcp.fx`; unit-tested in `instrument_drop_tests` at the boundary. The shell (`instrument_drop_win`)
calls the updated pure predicate unchanged.
**Verify (in DAW):** DAW-confirmation pending Daniel's post-merge smoke test — drop → a playing
instance on the track, one Ctrl-Z removes it.
- [x] Pure `instrument_drop` predicate widened: `infoNamesFxHotspot` now matches `fx_*` / `tcp.fx` /
`mcp.fx`; unit-tested at each matched and unmatched hotspot string.
- [x] Shell unchanged — calls the updated pure predicate.
---
## S-VIEW-SIZE-1 — 1080p default window size (Wave 1, interim)
**Goal:** The editor opens too small (`ViewRect(0,0,560,400)`); set a larger default sized for
the three-band Sample face on 1080p.
**What landed (interim):** default `ViewRect` bumped to **840×560** and a `checkSizeConstraint`
minimum floor added. The mechanism (`getSize`/`setRect`/`checkSizeConstraint`/`onSize`/`canResize`
in `vendor/vst3sdk/public.sdk/source/common/pluginview.h`) was verified correct at this point.
**NOTE:** Wave 2 (T-SHELL) will re-tune the final numbers to the three concrete Sample-face band
heights once the Sample view layout is built; 840×560 is the correct starting point, not the
final tuned value.
**Verify (in DAW):** opens at 840×560 showing more of the editor surface than before; cannot
shrink below the floor. Final tuning deferred to T-SHELL.
- [x] Default `ViewRect` bumped to 840×560.
- [x] `checkSizeConstraint` minimum floor enforced.
---
# Phase S — editor view-model redesign (three views: Sample / Browse / Zone)
> **Additive Phase S sub-phase (S-VIEW; Daniel, 2026-07-27, r9).** Merged to dev (Wave 1
> 2026-07-27, Wave 2 2026-07-27 — `Merge pS-w2-t1-shell` + `Merge pS-w2-t2-velcurve`,
> Wave 3 2026-07-27 — `Merge pS-w3-velcurve-ui`). Integrated suite green.
> **S-VIEW complete — all ten points landed.**
## S-VIEW-1 — three-view navigation model
**Goal:** Retire the flat Browser|Zones toggle; introduce Sample (home/default), Browse (modal
overlay over Sample, select+confirm), Zone (dedicated surface, own button). Empty state surfaces
Browse as the dominant call-to-action. Fresh instance stays silent (S10 reversal). Fork
S-VIEW-F3 SETTLED — full-window overlay: Browse renders as a full-window modal over Sample
(not a centered sheet). See CONTEXT.md §S-VIEW navigation contract.
- [x] Flat Browser|Zones toggle retired; three-view model: Sample home, Browse full-window modal
over Sample, Zone dedicated surface with its own button.
- [x] Empty state surfaces Browse as the dominant call-to-action; fresh instance is silent
(S10 reversal preserved).
- [x] S-VIEW-F3 settled and implemented: Browse is a full-window modal overlay over Sample.
---
## S-VIEW-2 — Sample view (the new main face)
**Goal:** Compose the home face: enlarged **hero waveform** with the S11 markers (moved from
Browse), a **fenced root affordance**, the **Mono/Stereo toggle** (moved from Browse), and the
**"Modes-and-down" control strip** (Mode / Pitch engine / AHDSR|Trigger / AD pitch env — moved
from Zone's param panel, single-capture one-zone storage per S15-F2). Reference grammar:
Simpler / Phase Plant (labelled value-strip under a hero waveform).
- [x] Hero waveform with S11 markers (start/loop start/loop end) moved to Sample face.
- [x] Fenced root affordance on the Sample face.
- [x] Mono/Stereo toggle moved from Browse to Sample.
- [x] "Modes-and-down" control strip (Mode / Pitch engine / AHDSR|Trigger / AD pitch env) moved
from Zone's param panel to the Sample face (single-capture one-zone storage per S15-F2).
---
## S-VIEW-3 — envelope overlay + draggable nodes
**Goal:** Draw the amp envelope (AHDSR for Gate, fade/%-length for Trigger) as a curve over the
Sample waveform at accurate wall-clock time. Pure `envelope_overlay` module (params + frame-length
→ polyline; unit-tested); shell traces it in an accent hue. Breakpoints are draggable handles
(S-VIEW-F2 SETTLED): X → segment time, Y → level on level-breakpoint nodes (sustain drags both
axes), monotonic-in-time + range-clamped. Pure `envelope_edit` module (node hit-test +
pixel-delta→clamped-param inverse map; mirror of `card_drag`; unit-tested). Both surfaces
read/write the same `PerformanceZone` envelope fields. Wave 1 landed the pure modules;
Wave 2 wired them in `reasampler_editor.cpp`. The Trigger frames↔fraction conversion was
extracted to a new pure unit-tested `trigger_seam` module (startFrame threaded correctly).
- [x] **Wave 1:** `envelope_overlay` + `envelope_edit` pure modules landed and unit-tested.
- [x] **Wave 2:** `reasampler_editor.cpp` traces the overlay and wires draggable node handles.
- [x] **Wave 2:** `trigger_seam` pure module — Trigger frames↔fraction converter (pack and unpack
directions, unit-tested); `startFrame` threaded correctly through the seam.
---
## S-VIEW-4 — preview-trigger + velocity knob
**Goal:** A button firing the sampler at the loaded capture's root note through the live voice
engine (off the audio-thread commit path — no MIDI controller needed) + an adjacent velocity
knob. Preview velocity PERSISTS (S-VIEW-F1 SETTLED): `previewVelocity` on `ComponentState` via
an envelope bump to v6 (`src/vst/sample_map.h`), round-tripped through `getState`/`setState`
over `IBStream`. Zones payload untouched; older blobs lift to a mid default. Wave 1 landed the
field; Wave 2 landed the preview button + velocity knob UI.
- [x] **Wave 1:** `previewVelocity` field on `ComponentState`, envelope v5→v6 bump, processor
`getState`/`setState` round-trip, clamped 1..127.
- [x] **Wave 2:** preview button + velocity knob UI in `reasampler_editor.cpp`; RT-safe
off-thread preview-note mailbox on the processor.
---
## S-VIEW-5 — Browse reduced to choosing (modal over Sample)
**Goal:** Keep search + bank tabs + captures grid + scroll + selection; add confirm/cancel
(double-click loads). Remove the large waveform preview, Mono/Stereo toggle, root keyboard-strip
(all moved to Sample), and the loop-point labels + track-root message (cut). Render as a
full-window modal overlay (S-VIEW-F3 SETTLED — implemented here and in S-VIEW-1).
- [x] Search + bank tabs + captures grid + scroll + selection retained.
- [x] Confirm/cancel affordance added; double-click loads.
- [x] Large waveform preview, Mono/Stereo toggle, root keyboard-strip, loop-point labels, and
track-root message removed from Browse.
- [x] Browse renders as a full-window modal overlay over Sample (S-VIEW-F3 settled and shipped).
---
## S-VIEW-6 — key-tracking parameter
**Goal:** Per-`PerformanceZone` scalar on keyboard pitch tracking around the root (100% = 12-tone-ET,
0% = no tracking, 200% = double). Additive/version-bumped, defaults 100% (bit-identical). Key-track
math in the pure sampler core (unit-tested note/root/keyTrack → ratio), applied in both Varispeed
and Preserve repitch. Surfaces as a control on the Zone param panel + Sample control strip.
Wave 1 landed the field/math/apply; Wave 2 landed the UI control.
- [x] **Wave 1:** per-`PerformanceZone` `keyTrack` field (zones payload v6, default 100%
bit-identical), pure ratio math, applied in both Varispeed and Preserve engines.
- [x] **Wave 2:** UI control on the Zone param panel + Sample control strip.
---
## S-VIEW-7 — piano-key pattern on the keyboard strip
**Goal:** Overlay the actual alternating white/black (bright/dark per palette) key pattern over
the pastel spectral fill so the strip reads as a keyboard. Pure `keyboard_strip` gains a
natural/accidental predicate (12-tone, unit-tested); shell draws the two-tone overlay. Shared
by the Zone strip + Sample root affordance. Wave 1 landed `isNaturalKey`; Wave 2 drew the overlay.
- [x] **Wave 1:** pure `isNaturalKey` predicate on `keyboard_strip`, unit-tested.
- [x] **Wave 2:** `reasampler_editor.cpp` draws the two-tone overlay over the spectral fill.
---
## S-VIEW-8 — Zone view retained + wired
**Goal:** Keep +Add Zone / Delete, the per-zone keyboard strip (now with the piano pattern), the
Low/High/Root numeric-entry legend, and the per-zone param panel; add the key-tracking control.
Nothing from today's Zones view dropped.
- [x] +Add Zone / Delete retained.
- [x] Per-zone keyboard strip with piano-key pattern.
- [x] Low/High/Root numeric-entry legend retained.
- [x] Per-zone param panel retained.
- [x] Key-tracking control added to Zone view.
---
## S-VIEW-9 — velocity→amp transfer curve (pure core + engine application)
**Goal:** New pure `velocity_curve` module — eval (monotone cubic Hermite spline,
FritschCarlson slope limiting) + control-point editing (add/move/delete x-ordered +
box-clamped) + hit-test + pixel-delta→clamped-point inverse map; unit-tested at eval +
clamp/order boundaries. Additive `velocityCurve` field on `PerformanceZone` (instrument-owned,
D-B), zones-payload version axis bumped to **v7**; default = **flat y=1** (R10-F1 SETTLED —
Option A, Daniel 2026-07-27: "any velocity plays at full level"); older ≤v6 blobs lift to flat
y=1. Applied at `Voice::start()` — replaces `velocityGain_ = velocity / 127.0` with
`velocityGain_ = curve.eval(velocity)`, off the per-frame path (no new RT work).
**Default is a deliberate non-back-compat behavior change:** the prior engine used linear
`velocity/127`; existing zones' soft hits will play at full level after upgrade. Flagged and
accepted by Daniel.
- [x] Pure `velocity_curve` module: `eval(velocity 0127)→amp 01` via FritschCarlson monotone
cubic Hermite spline (no overshoot outside [0,1]; collinear points reduce to exact linear ramp).
- [x] Control-point editing: `addPoint`, `movePoint` (x-clamped between neighbours, endpoints
x-pinned), `deletePoint` (endpoints not deletable); `fromPoints` repair-on-deserialize.
- [x] Hit-test + inverse map: `pointAtPixel`, `resolvePointDrag` (pure; mirror of `envelope_edit`).
- [x] Additive `velocityCurve` on `PerformanceZone`, zones-payload v7; pre-v7 blobs lift to
flat y=1; `velocity_curve_tests` CTest target.
- [x] Applied at `Voice::start()` — `velocityGain_` now set from `curve.eval(velocity)`.
- [x] Default flat y=1 — back-compat caveat documented and accepted.
---
## S-VIEW-10 — velocity-curve editor UI (shell, Sample + Zone views)
**Goal:** Draggable transfer-curve editor rendered through the L1 kit in BOTH the Sample view (a
curve box beside the hero band) and the Zone per-zone param panel: the velocity→amp spline drawn
as the `eval` polyline over a 0127 × 01 box, with draggable control points — add on empty-click
(inside the mapping box), move, Alt-click delete interior, drag-off-box delete with a warn-state
affordance. All coordinate math in the pure `velocity_curve` module (two new tested helpers
`pixelFromPoint`/`pointFromPixel`); reads/writes the existing per-`PerformanceZone`
`velocityCurve` field (zones payload v7 — no schema change). Mirrors the S-VIEW-3 envelope-node
interaction grammar (snapshot-at-grab → off-audio-thread commit). Shell: `reasampler_editor.cpp`.
- [x] `pixelFromPoint` / `pointFromPixel` helpers added to `velocity_curve` — coordinate mapping
between curve-point space (velocity 0127, amp 01) and pixel box; unit-tested in
`velocity_curve_tests`.
- [x] Curve editor drawn in Sample view (curve box beside hero waveform band): `eval` polyline in
accent hue over 0127 × 01 grid, draggable node markers per control point.
- [x] Add on empty-space click inside the box; move on drag; Alt-click to delete interior points;
drag-off-box delete with warn-state affordance.
- [x] Same editor in Zone per-zone param panel (one curve per zone, same interaction grammar).
- [x] Snapshot-at-grab → off-audio-thread commit, matching S-VIEW-3 envelope-node pattern.
- [x] Reads/writes existing `velocityCurve` on `PerformanceZone`; zones payload remains v7 — no
schema change.