Files
reasampler/COMPLETED.md
T
daniel 15d7bd80c0 docs(plan): archive landed and abandoned work per PLAN.md convention
Moves the shipped 9000 rename, Milestone 9 (abandoned), Phase D2, Phase L,
and S-VIEW-1-10 to COMPLETED.md. Restores the held-scope heading and
deduplicates Phase L.
2026-07-27 21:51:37 -04:00

174 KiB
Raw Blame History

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.

  • Delete src/mpe_model.{h,cpp} and src/mpe_view.{h,cpp}; remove tests/test_mpe_model.cpp.
  • 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.
  • Replace the pure mpe_model static lib + mpe_model_tests executable with bank_model (pure static lib) + bank_model_tests; keep the CTest wiring.
  • 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.
  • 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).
  • 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-ARCHIVE.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).

  • 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-ARCHIVE.md §Data model.
  • BankIndex: ordered collection keyed by id; add / remove / query.
  • Hash lookup for dedup-by-content-hash.
  • Tier model (scratch | archive) + tier-move + tier filtering; scratch marked auto-prunable.
  • JSON serialize/deserialize to/from std::string.
  • 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-ARCHIVE.md §Module architecture, CONTEXT.md §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).

  • Min/max bin computation from interleaved PCM given a target bin count.
  • Multi-channel handling (per-channel envelope; no silent fold).
  • 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-ARCHIVE.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).

  • ICaptureBackend interface + CaptureRequest (source mode, time range, wet/dry, tail, SR/bit-depth/channels, output path). CONTEXT-ARCHIVE.md §capture.
  • OfflineRenderBackend: drive GetSetProjectInfo render settings + GetSetProjectInfo_String file/pattern/format; verify every flag against vendor/reaper-sdk/sdk/reaper_plugin_functions.h.
  • Resolve the no-dialog render command/flag on the current REAPER build (open question) and confirm it runs headless.
  • Populate a Sample from the finished file; hand to bank_model; console-log.
  • 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-ARCHIVE.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).

  • SetProjExtState / GetProjExtState under namespace "reasampler".
  • Bank-folder resolution from the current project path (EnumProjects / GetProjectPathEx); store under a project-relative subfolder.
  • 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-ARCHIVE.md §bank_panel. Verify (in DAW): Grid docks; thumbnails render from computed peaks; audition plays selected sample; multi-select + keyboard nav work.

  • Docked window + LICE grid render loop.
  • Thumbnail draw from peaks bins.
  • Audition (play selected sample) + stop.
  • 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-ARCHIVE.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.

  • InsertMedia(path, mode) at edit cursor (verify mode bits against SDK).
  • Conform-to-project-tempo vs literal as an explicit flag (never silent).
  • 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-ARCHIVE.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.

  • Mode registry: ordered (id, display name, ordinal); Arrange + Design seeded; add/query more modes (prove N-mode, not binary).
  • Membership index: GUID → { mode ids } + per-track show-both flag; add / remove / retag / query; untagged = Arrange.
  • 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).
  • Parking/restore planner: emit exact (track, flag, value) op-lists for park and restore from active mode + snapshot record.
  • JSON round-trip: modes + membership + show-both + snapshots + active mode.
  • 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-ARCHIVE.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.

  • Build parent↔child tree from I_FOLDERDEPTH; feed to view_mode_model.
  • Snapshot prior flag values (GetMediaTrackInfo_Value) before parking.
  • Apply park/restore ops (SetMediaTrackInfo_Value for the four flags; TrackFX_GetCount + per-FX TrackFX_SetOffline). Verify flag names/signatures.
  • GUID resolution: GetTrackGUID / guidToString / stringToGuid (never index).
  • 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-ARCHIVE.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").

  • Serialize/deserialize the view section under "reasampler" (shared blob, distinct section from the bank index).
  • Reapply active mode on project open (rebuild tree, run the planner).
  • Confirm survival across Save / Save As; snapshot durability across save-while-parked.

D4 — actions

Goal: Bindable action set for the mode workflow. CONTEXT-ARCHIVE.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.

  • Toggle active mode (cycle; extensible to cycle-all for >2 modes).
  • Activate mode: Arrange / Activate mode: Design (direct jumps).
  • Tag selected tracks → Design / → Arrange; Untag selected (= → Arrange).
  • Show-both for selected tracks (toggle).
  • 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.

  • Segmented mode switch [ Arrange | Design ] in the window header; active lit.
  • Wire the switch to the toggle/activate actions from D4.
  • Per-mode membership count display.
  • 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.

  • Lane↔mode mapping: which lane maps to which mode, which C_LANEPLAYS value per mode.
  • 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.
  • "Which lanes may this toggle touch" query (managed only) — planner emits lane ops for managed lanes only, never for manual lanes.
  • Auto-tag decision (pure): new track/item GUIDs + active mode ⇒ membership writes; manual-lane items exempt; pre-existing ⇒ Arrange.
  • JSON round-trip of the lane-ownership index.
  • 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.

  • 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.
  • 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).
  • 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.
  • 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-ARCHIVE.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.

  • Source resolvers: master mix, selected tracks, selected items, razor area (GetSet_LoopTimeRange, P_RAZOREDITS, CountSelectedMediaItems, etc.).
  • Register each as a bindable action (command_id/gaccel/hookcommand).
  • Wet-dry + tail options per action.
  • 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 actionscapture 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-ARCHIVE.md §capture (realtime), CONTEXT.md §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.

  • 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.
  • 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.
  • 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.
  • 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.
  • 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).
  • 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.

  • 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.
  • 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.
  • CaptureRequest three-state tail contract (None/Auto/Manual(ms)); default None (exact bounds, null-test-safe). The earlier renderTail bool/tailMs pair was superseded.
  • 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).
  • 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.

  • Record [start, end + clamp(tail, 8 s)] (extend the record time selection in capture_realtime.cpp); Manual skips the scan, Auto proceeds to it.
  • 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.)
  • 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.

  • adjustManualMs(current, notches, stepMs) pure helper in tail_control (per-notch ±kManualStepMs = 250 ms, clamped [0, kMaxTailMs]); unit-tested.
  • tailToggleLabel updated: Manual mode appends the clamped length in seconds to one decimal, e.g. "Tail: Manual 2.0s"; unit-tested at boundary lengths.
  • Panel footer scroll-wheel handler calls adjustManualMs and repaints; click handler unchanged (still cycles mode via cycleTailMode).
  • serializeTailSetting / deserializeTailSetting pure round-trip (mode + manualMs) added to tail_control; unit-tested including std::nullopt on malformed input.
  • 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).
  • 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.

  • 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.
  • 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).
  • 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).
  • 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.
  • 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.
  • 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.
  • 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-ARCHIVE.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.

  • "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.
  • "Move selected items → Arrange" action: retags selected items' membership to Arrange mode, re-drives mint/apply; manual-lane items exempt; one undo block.
  • "Untag selected items" action: removes selected items' membership, re-drives mint/apply; manual-lane items exempt; one undo block.
  • All three registered (command_id/gaccel/hookcommand); MIDI-bindable.
  • 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.
  • 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.
  • 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_fileversion_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.

  • 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.
  • 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.
  • 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).
  • 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).
  • 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.

  • 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.
  • 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.
  • 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.
  • 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.
  • Bank-panel footer version/channel readout: displays appVersion() (stable: "0.9.01", beta: "0.9.01-beta").
  • The lane-name reasampler: prefix is deliberately NOT channel-qualified (shared naming convention; ownership isolated by namespace).
  • 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-ARCHIVE.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.

  • 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).
  • Pool-privilege rules enforced in-model: reject delete-pool, reject rename-pool, reject evacuate-pool, never allow zero banks.
  • Active-bank id (get/set; defaults to pool); resolve active bank's BankIndex.
  • Move sample between banks (index-only; destination collapse-by-hash observed; source entry removed).
  • Copy sample between banks (index-only; source entry retained; destination collapse-by-hash observed).
  • Evacuate bank: move every member to the pool (index-only; destination collapse-by-hash observed), leaving the bank empty; pool cannot be evacuated.
  • JSON round-trip: pool-as-bank-zero inside the blob + named banks + per-bank indices + ordinals + active id.
  • Legacy migration: a bare bank_index JSON promotes to the pool's index with zero named banks (one-way, lossless; blob authoritative thereafter).
  • 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).

  • 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).
  • 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-ARCHIVE.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.)

  • 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).
  • Legacy-migration path on load: absent banks + present bank_index → promote into pool, mint the blob, treat blob as authoritative (legacy key retired).
  • 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).
  • Confirm survival across Save / Save As; confirm legacy-project load path.

B3 — actions

Goal: Bindable action set for the multi-bank workflow. CONTEXT-ARCHIVE.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.

  • Create bank / rename bank / delete bank (delete drops member index entries; confirm-on-non-empty offered at the UI layer in B4).
  • Evacuate bank → pool (move all members back to the pool; refuses on the pool).
  • Activate bank (direct-by-id + cycle).
  • Move selected samples → bank / copy selected samples → bank (move is default).
  • Pool full-height / banks full-height toggles.
  • 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-ARCHIVE.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.)

  • Vertical split: pool grid region (top) + named-banks tab-page region (bottom).
  • 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.
  • Tab-strip overflow/scroll affordance (fork 5a): scroll/chevron overflow shipped with the strip.
  • Pool full-height / banks full-height toggle affordances wired to B3.
  • Active-bank indicator — visually unmistakable (settled constraint).
  • Create / rename / delete / activate / evacuate affordances driving B3 actions.
  • Delete confirms on a non-empty bank, naming the evacuate alternative.
  • 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.
  • 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).

  • Surface BankIndex::remove through bank_book: remove a Sample from a bank's index; pool contents removable, pool-container privileges unchanged.
  • "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).
  • bank_panel remove affordance on the current selection (reuse M5 selection model, as move/copy do).
  • 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.
  • 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-ARCHIVE.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.

  • 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).
  • "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.
  • 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-ARCHIVE.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.

  • Prune-reconcile pure function: (present, referenced, owned) → orphans, computing (owned ∩ present) referenced; referenced unioned across the whole book (copies keep a file alive).
  • 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-ARCHIVE.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.

  • Prune shell: enumerate the resolved current bank folder; feed the pure core.
  • 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.
  • 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) + CONTEXT-ARCHIVE.md §Prune (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.

  • "Prune bank folder" action (command_id/gaccel/hookcommand), dry-run-first, confirm-to-delete.
  • 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.
  • 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.
  • 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-ARCHIVE.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.
  • 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.
  • 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.
  • 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-ARCHIVE.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.

  • 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.
  • 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).
  • 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.

New pillar, own lettered namespace, taken up by a parallel team. Phase L is the whole-system look-and-feel effort: a shared LICE drawing kit and the surfaces that adopt it, so ReaSampler and ReaSampler 9000 shed the flat "temple os" drawing for a modern, sleek 2026 dark synth look. It answers Daniel's post-DAW-test verdict on the instrument ("this looks like temple os… the VST is dogshit… scope it for the whole system… does Cockos have a toolkit?"). Namespaced L (Look-and-feel) so it is orthogonal to and ungated by the M/D/B/R/V/S pillars — a parallel team owns it while Phase S feature work proceeds independently. Authoritative spec: CONTEXT.md §Phase L — visual design language (design-system spec). Product framing, the settled decision record (DS-1/DS-2/DS-3 all SETTLED 2026-07-26), palette, and the three visual directions: docs/product/visual-design-language.md.

L1 (shared LICE drawing kit — the foundation), L2 (dock-panel layout redesign), L3 (VST editor + embed-strip restyle), L4 (dock-panel button layout enhancement), L5 (dock-panel button refinements), L6 (toolbar polish), and L7 (capture ordering, card metadata, and selection styling) have all landedtheme/palette module, component_geometry geometry/hit-test helpers, draw_kit shell, GDI DrawText retirement in bank_panel (L1); action_bar pure task-grouped layout module, full M11-aware button inventory placed by task cluster, bank_panel redesigned through the L1 kit (L2); VST editor (reasampler_editor.cpp) + embed strip (reasampler_embed.cpp) restyled through the L1 kit — REAPER-grey neutrals + three pastel accents, pastel spectral keyboard strip + zone bars, hover/pressed/drag states, local kCol* forest-green palette retired (L3); three-zone layout (top capture/placement/maintenance toolbar, bottom Design-View toolbar, footer toggle + Tail button + Prune), footer_bar pure module, ActionCluster::Tagging/ Switching in action_bar (L4); top-bar overflow menu (overflow_menu pure module), custom LICE-kit hover-delay tooltips (tooltip pure module), opposite-mode Item/Track tag buttons + Show Both, Toggle + Activate-Arrange/Design buttons removed, grouping spacing widened (mode_enable pure module) (L5); single-row button faces, keybinding in tooltip, Cancel RT moved to overflow, top-bar cluster order tidied (L6); per-bank SlotMap (id→slot) in bank_book, sparse-grid rendering, card_drag + card_meta pure modules, captureTimeSigNum/captureTimeSigDenom on Sample, tertiary-border selection (L7). See individual entries above. Phase L is complete.

This section is self-contained for a team without Phase S context. Where a point touches a Phase S surface (the VST editor, the embed strip, the keyboard strip), the gate is stated explicitly so the team does not chase files that are not on dev yet.

Settled decisions (Daniel, 2026-07-26 — see docs/product/visual-design-language.md §6):

  • DS-1 — toolkit: LICE + WDL free game, no external frameworks. Draw the modern look with LICE directly; reuse any useful WDL/vwnd piece (skin/image helpers, draw idioms, a control like the scroll listbox) where it beats re-deriving — "don't reinvent the wheel." Reject iPlug2 / JUCE / VSTGUI (external frameworks re-opening the settled bare-SDK+LICE build shape). Keep hit-test geometry in pure CTest-covered modules — do not import vwnd's retained-mode object model wholesale.
  • DS-2 — visual direction: Direction B ("Neon Console") + Direction C's spectral keyboard strip. SETTLED 2026-07-26, REVISED 2026-07-26 (Daniel) — palette-only. Neutral surfaces (revised): the neutral ladder moved from near-black up into REAPER's mid-grey theme family so the dock reads as part of REAPER, not a black slab — bg/base#2b2b2b, bg/panel#333333, bg/cell#3a3a3a, line/hairline#4a4a4a, text/primary#dcdcdc, text/dim~#a0a0a0+ (elevation-ladder discipline unchanged). Accent layer (revised): now a three-accent pastel systemaccent/primary pastel lime green (live/active/selected), accent/secondary pastel teal
    • accent/tertiary pastel purple (categorical distinctions) — replacing the original single electric cyan. The spectral keyboard strip is a pastel sweep anchored on the three accents. Tight WCAG pairs to re-verify against the grey ladder: text/dim-on-grey (mid-grey-on-mid-grey, AA 4.5:1) and the three pastels-as-indicators on bg/cell (shrunk from ~15:1 to ~6:17:1). A stylish/bundled-font upgrade was considered and DECLINED (Daniel): no font bundling/redistribution — the kit keeps its current cached-font face, no new typeface. The kit palette stays abstract (roles, one constants block — three accent roles + grey neutrals), so the direction is a single-file change; final hex is locked against the theme WCAG tests within the pastel intent. Detail: docs/product/visual-design-language.md §2.1/§4/§6 + CONTEXT.md §Phase L.
  • DS-3 — dock-panel scope: a thorough layout redesign, not a light re-skin. L2 lays out the full button inventory (including M11's action-button additions) intuitively, uncluttered, and useful — then applies the kit. Sequenced after M11 merges.

Phase L — sequencing

L1 (shared kit) ──► L2 (dock-panel layout redesign) [LANDED]
        ├─────────► L3 (VST editor + embed-strip restyle) [LANDED]
        └─────────► L4 (dock-panel button layout enhancement) [LANDED]
                            └────► L5 (dock-panel button refinements; ungated, after L4) [LANDED]
                                        └────► L6 (toolbar polish; ungated, after L5) [LANDED]
                                                    └────► L7 (capture ordering + card metadata + selection styling; ungated, after L6) [LANDED]

L1, L2, L3, L4, L5, L6, and L7 have all landed. Phase L is complete.

Phase L — must-verify-before-build

  • LICE design-kit surfaces (L1)LICE_GradRect, LICE_RoundRect, AA LICE_Line/LICE_FLine/LICE_ThickFLine/LICE_Circle/LICE_FillCircle/ LICE_DrawCBezier, LICE_FillTriangle/FillTrapezoid/FillConvexPolygon, and the LICE_CachedFont/LICE_IFont font engine (SetFromHFont, AA DrawText, shadow/outline/ glow FX flags). Verified present in vendor/WDL/WDL/lice/lice.h + lice_text.h (design-language doc §1.1); confirm exact signatures + the LICE_CachedFontHFONT lifecycle at build.
  • WDL/vwnd reuse assessment (DS-1) — at build time, evaluate whether a vwnd piece beats re-deriving it: virtwnd-slider.cpp / vwnd_slider_drawknobstack as the slider/knob drawing reference, virtwnd-listbox.cpp as a candidate scroll listbox, virtwnd-controls.h for WDL_STYLE_* gradient hooks, virtwnd-skin.h for image-skin helpers. Reuse where useful; keep hit-test geometry pure regardless.
  • M11 button inventory (L2) — resolved at L2 build: inventory taken against dev after M11 merged; all buttons placed by task cluster in the landed action_bar module.
  • L4 re-home surface (L4) — resolved at L4 build: three-zone layout confirmed against the post-palette-revision bank_panel; action_bar extended with ActionCluster::Tagging + Switching; new pure footer_bar module covers footer layout/hit-test.
  • L5 refinement surface (L5) — resolved at L5 build: TrackPopupMenu overflow menu confirmed; tooltip mechanism resolved as custom LICE-kit hover-delay tooltip (tooltip pure module; sourced from the registered action phrase, prefix stripped at draw time); item-move and track-tag action ids confirmed; active-mode read confirmed via the same view().activeModeId() the footer toggle uses.
  • L7 model + tempo surface (L7)RESOLVED at build (L7 landed). Forks F1/F2/F3 all confirmed: TimeMap_GetTimeSigAtTime confirmed at build for the meter stamp; SWELL stock cursors chosen for drop-result cues (Reorder→IDC_SIZEALL, Move→IDC_HAND, Copy→IDC_UPARROW, Replace→IDC_SIZEWE); existing pool-privilege guard reused as-is for Alt-replace. Gap navigation in the grid = skip gaps (arrow keys skip empty slots). See individual entries §L7 above.

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-ARCHIVE.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.

  • 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.
  • 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.
  • 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_CachedFonts (kit-owned lifecycle), full interaction-state model, double-buffer preserved.
  • 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-ARCHIVE.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.

  • 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.
  • 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-ARCHIVE.md §Phase L (L4 dock-panel button layout). Product framing: CONTEXT-ARCHIVE.md §L4 dock-panel button layout. 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).

  • 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.
  • 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.
  • 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.
  • Footer Tail button: Tail click-zone converted to a proper kit button (rest/hover/ pressed states; click still cycles the tail setting).
  • 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.

  • 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.
  • Short faces + drop ReaSampler: prefix on the button face; keep the keybinding micro sub-row.
  • 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.
  • 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.
  • 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.
  • 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.
  • 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.

  • 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.
  • Keybinding in hover tooltip: tooltip now renders "phrase — binding" when the action is bound, bare phrase when unbound (live binding via kbd_getTextFromCmd).
  • 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.
  • 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.

  • 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).
  • 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.
  • 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).
  • 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.
  • 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.
  • 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).
  • 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.
  • bank_panel metadata overlay: bars.beats bottom-left (Micro), s.ms bottom-right (ValueMono), TextDim, decorative / non-interactive.
  • 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).

  • 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).
  • 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.
  • 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.
  • 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-ARCHIVE.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).

  • Voice engine: polyphonic voice allocation (note-on/off, bounded voice stealing), per-voice state, mono-and-basic-polyphony sufficient for Tier 0.
  • Amplitude envelope (ADSR) math — asserted against a known signal.
  • Repitch/interpolation from root note (chromatic pitch ratio across the keyboard); loop-point-aware sustain for held notes.
  • 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.
  • 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.

  • 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.)
  • 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.
  • 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.
  • 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.

  • 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).
  • 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.
  • 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.

  • Implement IReaperUIEmbedInterface on the VST3; draw a compact keymap/level strip inline in the TCP/MCP using the same LICE surface as the editor.
  • 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.

  • 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.
  • 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.
  • 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.
  • 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.

  • 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.
  • 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.
  • 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-ARCHIVE.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-ARCHIVE.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-ARCHIVE.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.

  • 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).
  • 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.
  • 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-ARCHIVE.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.

  • 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.
  • 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 modeGate (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.

  • Core: PlayMode { Gate, Trigger } on the voice + the envelope split. Gate grows AdsrParamsAhdsrParams (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.
  • 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.
  • 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).
  • 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.
  • 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.

  • Core: pitch-engine mode on the voice/zonePitchEngine { 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.
  • 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).
  • 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.
  • 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 DragGestureInstrumentDrop — 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-ARCHIVE.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).

  • 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.
  • 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.
  • 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.
  • 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 (buildInstrumentDropChunkserializeComponentState → 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.
  • 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 #ifdefs. 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).

  • 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.
  • Channel-derived binary + display identity (no scattered #ifdefs). 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.
  • 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).
  • 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.
  • 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.

  • Pure instrument_drop predicate widened: infoNamesFxHotspot now matches fx_* / tcp.fx / mcp.fx; unit-tested at each matched and unmatched hotspot string.
  • 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.

  • Default ViewRect bumped to 840×560.
  • 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.

  • Flat Browser|Zones toggle retired; three-view model: Sample home, Browse full-window modal over Sample, Zone dedicated surface with its own button.
  • Empty state surfaces Browse as the dominant call-to-action; fresh instance is silent (S10 reversal preserved).
  • 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).

  • Hero waveform with S11 markers (start/loop start/loop end) moved to Sample face.
  • Fenced root affordance on the Sample face.
  • Mono/Stereo toggle moved from Browse to Sample.
  • "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).

  • Wave 1: envelope_overlay + envelope_edit pure modules landed and unit-tested.
  • Wave 2: reasampler_editor.cpp traces the overlay and wires draggable node handles.
  • 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.

  • Wave 1: previewVelocity field on ComponentState, envelope v5→v6 bump, processor getState/setState round-trip, clamped 1..127.
  • 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).

  • Search + bank tabs + captures grid + scroll + selection retained.
  • Confirm/cancel affordance added; double-click loads.
  • Large waveform preview, Mono/Stereo toggle, root keyboard-strip, loop-point labels, and track-root message removed from Browse.
  • 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.

  • Wave 1: per-PerformanceZone keyTrack field (zones payload v6, default 100% bit-identical), pure ratio math, applied in both Varispeed and Preserve engines.
  • 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.

  • Wave 1: pure isNaturalKey predicate on keyboard_strip, unit-tested.
  • 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.

  • +Add Zone / Delete retained.
  • Per-zone keyboard strip with piano-key pattern.
  • Low/High/Root numeric-entry legend retained.
  • Per-zone param panel retained.
  • 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.

  • 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).
  • Control-point editing: addPoint, movePoint (x-clamped between neighbours, endpoints x-pinned), deletePoint (endpoints not deletable); fromPoints repair-on-deserialize.
  • Hit-test + inverse map: pointAtPixel, resolvePointDrag (pure; mirror of envelope_edit).
  • Additive velocityCurve on PerformanceZone, zones-payload v7; pre-v7 blobs lift to flat y=1; velocity_curve_tests CTest target.
  • Applied at Voice::start()velocityGain_ now set from curve.eval(velocity).
  • 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.

  • 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.
  • 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.
  • 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.
  • Same editor in Zone per-zone param panel (one curve per zone, same interaction grammar).
  • Snapshot-at-grab → off-audio-thread commit, matching S-VIEW-3 envelope-node pattern.
  • Reads/writes existing velocityCurve on PerformanceZone; zones payload remains v7 — no schema change.

Phase S — product name (ReaSampler 9000)

The MIDI-playback instrument's product name is ReaSampler 9000 (Daniel, 2026-07-26, on DAW-testing the S1S6 instrument). The extension remains ReaSampler; the instrument is ReaSampler 9000. Framing + propagation surfaces: docs/product/midi-playback.md §Product name.

  • Propagate the display name ReaSampler 9000 across user-visible surfaces: the VST3 class display name string (in the factory registration), the IPlugView editor title band (currently "ReaSampler Instrument"), the S6 embed-strip label, and the Phase S docs. Do NOT change the VST3 class UID — instances in already-saved projects key off it; a UID change orphans every existing instance.
  • Rename the binary filename too (S-NAME-1 SETTLED, Daniel 2026-07-26): rename the built VST3 module (CMake OUTPUT_NAME / target artifact — e.g. reasampler_9000.vst3) alongside the display strings, so the on-disk name matches the product name. Record the full rename surface: CMake output name (the second VST3 target's artifact name), the factory vendor/name strings, the IPlugView editor title, and the S6 embed label. Do NOT touch the VST3 class UID (unchanged — the compat anchor).

Phase S — S-VIEW-1 through S-VIEW-10 (rolled-up)

  • S-VIEW-1 through S-VIEW-10 — all landed (Wave 1 core + Wave 2 shell + Wave 3 velocity-curve editor UI). See entries §S-VIEW-1 through §S-VIEW-10 above for the full entry set.

Phase D2 — Two-canvas (item-level mode projection; additive to D1)

Design View sub-phase. Extends D1's track-level mode projection to item level via REAPER 7 fixed lanes: on a track present in both stances, each mode owns a fixed lane — the active mode's lane shows and plays, the inactive mode's is hidden and silenced — so a Design take and an Arrange take can share the same track and time position without colliding on the view. Nothing in D1 changes. Runtime floor rises to REAPER 7 for this sub-phase (no version-gate branch; below v7 it is simply unavailable). Authoritative spec: CONTEXT.md §Two-canvas sub-phase (Phase D2 / Phase E) and the surrounding §Design View — additive phase spec. Product framing: docs/product/design-view.md §Two-canvas direction.

D2-W1 (pure lane extension), D2-W2 (shell: lane application + new-content detection), D2-W3-A (lane minting + item→lane assignment + persist round-trip), and D2-W3-B (item-level mode actions + W3-A polish) have all landed — see individual entries above. Phase D2 is functionally complete.

Deferred: panel UI indicator for per-track lane/mode state (a per-track lane-split marker). The mode switch already shows the active mode; no natural cheap home for a per-track indicator was found in the bank panel. Explicitly deferred — not silently dropped. Can be picked up later if wanted.


Milestone 9 — slots (MPC-style)

Abandoned (Daniel, 2026-07-27) — will not be built.

Goal: "Capture to slot N" / "insert slot N", MIDI-bindable. CONTEXT-ARCHIVE.md Build order 9. Verify (in DAW): Slot capture and slot insert fire from MIDI bindings; slot state persists via the index.

Slot model + slot↔sample assignment. "Capture to slot N" / "insert slot N" actions, MIDI-bindable.