Files
reasampler/COMPLETED.md
T

146 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.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.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.md §Module architecture, §Non-goals. Verify: CTest green. Fed a known signal (full-scale sine, ramp), asserted min/max envelope per bin matches expected within tolerance; channel count preserved; bin count honored for arbitrary sample lengths (incl. remainder bin).

  • 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.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.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.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.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.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.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.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.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.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.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.md §capture (realtime), §Precision invariants. Verify (in DAW): Hidden temp track taps each selected track's own post-fader output via a CreateTrackSend; recorded file moves into the bank; non-destructive — temp track (and its sends) removed cleanly, every snapshotted track arm, time selection, and edit cursor restored unchanged on every terminal path.

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

L1 — shared LICE drawing kit (the foundation)

Goal: Stand up the shared LICE-based drawing kit that every Phase L surface (L2 dock panel, L3 VST editor) consumes — palette/theme module, pure component geometry/hit-test helpers, LICE draw shell, and retirement of the GDI DrawText path in bank_panel. CONTEXT.md §Phase L (Kit architecture). DS-1 (LICE + WDL, no external frameworks) and DS-2 (Direction B Neon Console + Direction C spectral) are the governing settled decisions. Verify: CTest green (theme_tests, component_geometry_tests). Each text-on-surface pair in the palette clears its WCAG floor (tested). bank_panel text routes through cached-font text() — GDI DrawText path retired. Double-buffer discipline preserved.

  • 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.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.md §Phase L (L4 dock-panel button layout). Product framing: docs/product/visual-design-language.md §L4. Verify (in DAW): the top toolbar fires every capture + placement + maintenance action; the bottom toolbar tags/untags selected tracks and switches/toggles Arrange/Design/show-both; the footer shows a narrow [Arrange|Design] toggle at the left, a Tail button that cycles tail on click with button states, and the Prune button set apart at the right in warn; every surface draws through the L1 kit in the DS-2 grey+pastel palette; capture and placement still never auto-insert (the buttons only fire the existing, unchanged actions).

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

Phase S — MIDI-playback instrument (native VST3 sampler; a second build artifact)

Landed on dev (merged 2026-07-27); DAW verification pending Daniel's smoke test. S1S18 are all on dev. The cross-artifact ingest relay (one S13 bullet) was explicitly DEGRADED and remains deferred in PLAN.md. Authoritative spec: CONTEXT.md §MIDI-playback instrument — additive phase spec (Phase S). Product framing: docs/product/midi-playback.md.


S3 — pure sampler core (voice engine / envelope / keymap / repitch)

Goal: The REAPER-free and VST3-free sampler core — voice allocation/polyphony, amplitude envelope (ADSR), key→sample and velocity→sample mapping (the keymap), repitch/interpolation from root note, keymap resolution — unit-tested in CTest against known signals. The heart of the phase (D3); the mirror of bank_model/peaks/view_mode_model/bank_book; test it hard. The core is invariant under the build-shape choice — no VST3 or REAPER type at its boundary. CONTEXT.md §Phase S (pure core, module architecture). Verify: CTest green. Voice allocation is correct under polyphony (note-on/off, voice stealing where bounded); ADSR shape asserted against a known signal (mirror of peaks); repitch from root note produces the expected pitch ratio; keymap resolution maps a (note, velocity) to the correct sample/zone; the core takes and returns only plain data (no VST3/REAPER types) — enforced by the test target linking neither SDK. Depends on: S2 (consumes rootNote / loop points as core inputs).

  • 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.md §Phase S (ReaSampler 9000 UX — capture-first editor).

Policy reversal — fresh instance is SILENT, nothing auto-selected (was S4). The S4 "first sample plays" fallback is removed: on open with no stored selection, the instrument plays nothing and the editor shows a clear empty state ("pick a capture") — it does not auto-play sample #1. Retires the selectSample first-sample fallback (sample_map.cpp "No stored id → fall back to the FIRST sample") and the processor's Tier-0 fallback that resolved it; an empty stored id now resolves to silence. A capture is loaded when the user picks one (or via S13 drop-to-load / S8 ingest). This is a deliberate reversal of the S4 convenience default, not a regression.

Verify (in DAW): a fresh instance plays nothing and shows the "pick a capture" empty state (no auto-play of sample #1); the capture browser draws peak thumbnails (the Sample peaks bank_model already carries — same data the dock panel thumbnails use), name, and a root/key badge where present, and is filterable by bank (bank_book named banks); picking a capture loads it, shows it (waveform/peaks + its root on the keyboard strip), and it plays repitched from its root; time-to-first-note is a pick-then-play, not a list-scroll; the keyboard strip shows the single capture's root and is draggable to set it; the pure geometry modules are CTest-green (browser card/grid layout + hit-test; strip edge-grab/body-move/key→note) with no host types at their boundary; the ±1 nudge-button row is gone. Depends on: S4 (the selection state + reload path this reverses the fallback on), S1 (the LICE IPlugView drag/event routing — extends the click-only wndProc to WM_MOUSEMOVE/WM_LBUTTONUP), S5 (the PerformanceMap/zone model the opt-in Zones panel edits — but the default face does not require a keymap). Adopts the Phase L kit when available — not gated on Phase L. S10 builds its browser cards + keyboard strip with the current LICE drawing; when Phase L's L1 kit lands on dev, this surface adopts it (the one source of drawing). The drag machine's WM_MOUSEMOVE tracking also lights the kit's hover states at near-zero marginal cost once the kit is present.

  • No-auto-select + empty state (the policy reversal): remove the selectSample first-sample fallback (sample_map.cpp) and the processor's Tier-0 fallback that consumed it — an empty stored selection resolves to silence, not sample #1. The editor draws a clear empty state ("pick a capture" affordance) when nothing is selected. Pure change is testable (empty id → nullopt); the empty-state draw is shell.
  • Capture browser (pure layout + shell draw): grow SampleChoice to carry the peak thumbnail data (from the Sample peaks bank_model already stores — the same peaks the dock panel draws), the root/key badge (S2 rootNote intrinsic / the optional musical key), and its bank. A new pure capture_browser module lays out scannable cards/rows (card rect grid, thumbnail rect, hit-test a point → card) — no host types at the boundary, unit-tested. The shell draws each card's peak thumbnail + name + badge in LICE (house palette) and routes a click to select.
  • Bank filter (pure + shell): a filter/tab strip over the browser that narrows the drawn cards to a chosen bank_book bank (or "all"). Filter-tab layout + hit-test pure (mirror of mode_switch); the active-filter state is transient UI state; the shell draws the tabs and applies the filter to the card list. (Type-to-filter search folds in from S12 — see S12's boundary note; a name-substring filter over the same card list.)
  • Guided single-capture setup (the fast path): once a capture is picked, a prominent, self-explanatory setup surface — root note (settable on the keyboard strip / typed), play-mode basics, level — sized for the single-capture case, not a zone table. Graphic and descriptive; the point is to get from pick → set → play with no hunting.
  • Pure keyboard_strip geometry module (serves the single-capture case first): map a MIDI key span across a strip width (128 keys → pixels, reusing the S6 embed_strip key-span idiom); a root marker for the loaded capture; pixel→note and a keyAtPoint for click-to-set-root; a drag-delta resolver (grabbedField, startNote, dxPixels) → newNote; per-zone bar rect + edge-grab hit regions (resize handles vs. body move-handle) for the opt-in Zones panel. No VST3/REAPER/LICE types at the boundary; unit-tested (root marker, edge grabs, body-move delta, key mapping, clamps low≤high, boundary rounding). Mirror of mode_switch/editor_geometry.
  • Editor shell drag-state machine: WM_LBUTTONDOWN grabs a card / a key / a zone edge-or-body, WM_MOUSEMOVE updates the in-flight edit against the pure resolver, WM_LBUTTONUP commits via the existing commitMapAndReload (off-thread reload; RT path untouched). Live visual feedback while dragging; a single undo-coherent edit on release.

S10-Z — Zones panel (opt-in multi-zone keymap editing; demoted from the default face)

The multi-zone keymap editor is now an opt-in view/panel ("Zones" toggle), not the default. It reuses the same keyboard_strip geometry and drag-state machine: each zone a bar over the keys it covers; drag an edge → low/high note; drag the bar body → move the zone (span preserved); click a key → set/relocate the zone's root. This is the capability RS5K structurally lacks (multi-zone in one instrument), kept as a nice-to-have per Daniel's hierarchy — "most of the time the zones won't be used." Add/select/delete a zone; overlapping zones render legibly and resolve first-match. The seven ±1 nudge/delete mini-buttons are retired everywhere; delete is one affordance (a small × on the bar or a keystroke). The zoneHitTest/±1 nudge path in editor_geometry is retired (a numeric fallback for accessibility is a build-time residual, not a fork). Verify (in DAW): the Zones panel is reachable via an explicit toggle (default view is the capture browser + single-capture setup, not this); a zone's range is set by dragging edges (not nudge clicks); body-drag moves the span; click-a-key sets the root (audible on the next held note); zone add/select/delete work; the ±1 nudge row is gone.

  • "Zones" panel toggle (opt-in): the default editor face is the capture browser + single-capture setup; a toggle reveals the multi-zone keymap editor. Toggle state is transient UI state (or per-instance component state if it should persist — build-time residual).
  • Zone edit via the shared strip: draw the keyboard strip + zone bars in LICE, drive the shared drag-state machine (edge = resize, body = move, key = root), commit via commitMapAndReload. Zone add/select/delete as single affordances; ±1 nudge row gone.

S11 — waveform view with draggable loop points (UX overhaul, part 2)

Goal: Give each sample/zone a waveform display with draggable start/end/loop markers — the S2 loop-point intrinsics and the S5 performance map already carry the data; today there is no way to see a sample or set its loop by eye. Selecting a zone (or a bank sample) shows its waveform (peaks via the existing peaks module, fed the decoded PCM the shell already loads); drag the loop-start / loop-end markers to set the sustain loop, snapping to zero-crossings (the S2 spec's zero-crossing-aware requirement). Loop points are a performance-map override on the zone where set, seeded from the bank intrinsic (D-B split: the bank carries the file-fact default; the instrument's drag is the performance choice). All marker/waveform layout + hit-test is pure geometry; peaks compute reuses peaks; the draw + drag is the shell. CONTEXT.md §Phase S (ReaSampler 9000 UX — waveform view). Verify (in DAW): selecting a zone shows its sample's waveform; dragging the loop-start and loop-end markers sets the sustain loop and a held note audibly loops that region; markers snap to the nearest zero-crossing; a sample with no loop shows the "no loop" state and a held note past the end goes silent (existing core behavior); the waveform peaks match the audio (mirror of the peaks envelope assertion); the marker geometry module is CTest-green (px↔frame mapping, marker grab regions, clamp start≤end). Depends on: S2 (loop-point intrinsics), S3 (loop-aware sustain the markers drive), S5 (the zone the loop attaches to), S10 (shares the editor's drag-state machine + shell). The zero-crossing snap is a small pure helper over the decoded PCM.

Boundary note (S10 reframe, 2026-07-26): the waveform view is now central to the single-capture fast path, not just per-zone. Selecting a capture in S10's browser shows its waveform (this is "see it" in pick → see it → play it); the loop-marker drag here extends that same waveform surface. S11's waveform draw is the same one S10's picked- capture view uses — build it once, S10 shows it read-only for the single capture, S11 adds the draggable loop markers. No renumber; S11 stays the loop-editing point.

  • Pure waveform/marker geometry: frame↔pixel mapping across the waveform rect, marker x-position from a frame index, marker grab regions (start/end/loop-start/loop-end), drag-delta (grabbedMarker, dxPixels) → newFrame with clamps (start≤end, in-bounds). A zero-crossing snap helper: nearest sign-change frame to a target (pure, over the decoded mono PCM). No host types; unit-tested.
  • Waveform draw: compute peaks with the existing peaks module from the shell's already- decoded PCM (no new decode path, no new WAV reader); draw the envelope in LICE in the house style; draw the loop markers over it. Reuses the S10 drag-state machine.
  • Loop-point edit → performance-map override: a dragged loop writes a per-zone loop override (seeded from the S2 bank intrinsic, D-B), committed off-thread via commitMapAndReload; the bank intrinsic is never written back (instrument is a read-only bank consumer). Extends PerformanceZone with an optional loop override (additive, same shape as rootOverride) + its component-state (de)serialize (version bump, back-compat with S5's v2 map blob — a truncated/older blob defaults the override absent).

S12 — editor scale + ergonomics (UX overhaul, part 3; scrollable/searchable list, direct entry)

Goal: Make the editor usable at bank scale and close the remaining RS5K-parity gaps: the sample list scrolls (today a long bank's rows run off the panel with no way to reach them) and has a type-to-filter search; add direct numeric entry for a zone's low/high/root (a click-to-type field over the strip, for precision the drag can't hit) and an ADSR control for the amp envelope (S3 already has the ADSR math; today it is fixed — expose attack/decay/sustain/release as draggable sliders, per-instance state). This is the "sensible list handling + direct manipulation of the parameters that exist" tier. All slider/scroll/search-box layout + hit-test is pure geometry; the shell draws + routes; ADSR/scroll/filter state is instrument-owned (component state / transient UI state). CONTEXT.md §Phase S (ReaSampler 9000 UX — scale + ergonomics). Verify (in DAW): a bank with more samples than fit scrolls (wheel + drag) and every sample is reachable; typing filters the list to matching names; a zone's low/high/root can be typed (not only dragged) via a click-to-edit field; the amp envelope's ADSR is adjustable (four draggable controls) and the change is audible + persists across project save/reopen (component state); the scroll/search/slider geometry is CTest-green. Depends on: S10 (the editor shell + drag-state machine + the capture browser the scroll/search now apply to), S3 (the AdsrParams the ADSR sliders drive — already wired into the voice engine; today they are fixed defaults), S5 (the map the numeric fields edit).

Boundary note (S10 reframe, 2026-07-26): the "sample list" S12 originally scrolled and searched is now S10's capture browser (cards with peak thumbnails, bank filter). What pulled INTO S10: the browser layout itself, the peak thumbnails, and the bank filter (a bank_book tab, distinct from name search). What stays in S12 and applies to S10's browser: (a) scroll for a bank longer than the panel, and (b) type-to-filter search (a name-substring narrow over the same cards, composing with S10's bank filter — bank filter picks the bank, search narrows within it). The scroll/search geometry is pure, layered over the capture_browser module S10 builds. Net: S12 = scroll + search over the S10 browser + numeric entry + ADSR; the browser card work is S10's. S12 now also carries the S15/S16 control surfaces (per-zone Gate/Trigger mode toggle, AHDSR hold control, Trigger %-length/fade controls, Varispeed/Preserve engine toggle, and the AD pitch envelope depth/shape controls) — deferred here from S15 and S16 per spec.

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