L3 (VST editor + embed-strip restyle) is on dev. Append its COMPLETED entry and flip visual-design-language.md §5.3/5.4 from GATED to landed / Phase L complete. PLAN.md + CONTEXT.md L3 edits already landed via the S-VIEW commit.
150 KiB
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}andsrc/mpe_view.{h,cpp}; removetests/test_mpe_model.cpp. - Rename the CMake
project()and the extension MODULE target fromreaper_mpeviewtoreaper_reasampler(binaryOUTPUT_NAMElikewise); updatePREFIX ""/ platform SUFFIX blocks to the new target name. - Replace the pure
mpe_modelstatic lib +mpe_model_testsexecutable withbank_model(pure static lib) +bank_model_tests; keep the CTest wiring. - Repoint
src/main.cpp: drop thempe_view.hinclude and allMpeView_*calls (toggle / IsOpen / OnTimer / Cleanup); stub the extension entry so it loads, logs to console, and registers nothing MPE-specific. Thecommand_id/gaccel/hookcommandregistration 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.mdlayout/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
Samplewith 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).
ICaptureBackendinterface +CaptureRequest(source mode, time range, wet/dry, tail, SR/bit-depth/channels, output path). CONTEXT.md §capture.OfflineRenderBackend: driveGetSetProjectInforender settings +GetSetProjectInfo_Stringfile/pattern/format; verify every flag againstvendor/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
Samplefrom the finished file; hand tobank_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/GetProjExtStateunder 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", keysbank_index(serialized JSON) andproject_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 callsMarkProjectDirtyso 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. TheReaProject*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
peaksbins. - 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/StopPreviewAPI, read-only — display + select + audition only, never inserts into the arrange. Single stop-funnel ensures a leak-free preview lifecycle. Flagged undocumented assumption:StopPreviewdetaches the source before returning; mitigated by the single-funnel design. Escalation path if a runtime pop appears: switch toStartPreviewFade+ 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
InsertMediabase 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
&4stretch-to-time-selection bit is never set; a pureinsert_plantest 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 toview_mode_model. - Snapshot prior flag values (
GetMediaTrackInfo_Value) before parking. - Apply park/restore ops (
SetMediaTrackInfo_Valuefor the four flags;TrackFX_GetCount+ per-FXTrackFX_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 thecommand_id/gaccel/hookcommandcontract inmain.cpp; MIDI-bindable. - New
src/track_guid.{h,cpp}— sharedMediaTrack*→ 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(loadFromProjectraises it;main.cpp's timer drains it) —persiststays model-only. - A pure
nextModeIdfree function added toview_mode_model(the N-mode cycle decision behind "toggle"), unit-tested in the existingview_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 newmode_switch_testsCTest target. Mirror ofbank_grid. src/bank_panel.cpp— segmented[ Arrange | Design ]control drawn in the panel header (one lit segment per registered mode, click activates that mode viaview::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_LANEPLAYSvalue 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_HIDDENvia the item/track info setters;UpdateTimeline()afterI_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_FIXEDLANElane-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 theserialize()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) andlane_keys(manages lane identity via durableP_LANENAMErather than the renumber-proneI_FIXEDLANEordinal, reconciled each apply — the resolution to the lane-identity fragility design point). CTest green. - Manual-lane protection: a single pure predicate
isOnManualLaneis 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 toOfflineRenderBackend(RENDER_* snapshot/restore, dither/normalize off, 32-bit float), produces aSample, adds it to the bank, persists, and callsMarkProjectDirty. 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 (
&8192pre-fader stems) is post-FX. Approximate-dry action variants were removed rather than ship a "dry" that isn't.CaptureRequest.wetDryis retained as the seam for true dry (M10). - Pure
render_settingsmodule maps source mode → RENDER_SETTINGS bits and parsesP_RAZOREDITS(union of track-audio areas), unit-tested. No capture path inserts into the arrange (load-bearing gate); non-destructive (selection/razor read-only).
Superseded / reworked (post-landing):
- The four wet source-mode actions (master/tracks/items/razor) were replaced by three FX-scope actions —
capture item,capture track,capture master— with range (razor-else-time-selection) inferred orthogonally. This fixed the defect where item captures were rendered through the parent FX chain. - FX-scope semantics (initial rework): item = item/take FX only; track = item FX + the selected track's own track FX; master = full chain. For item/track, the out-of-scope chain (ancestors + master, plus the item's own track for item scope) is neutralized during the render.
- Master scope subsequently removed: capture is now two scopes — item and track only.
CAPTURE_MASTERandCAPTURE_MASTER_REALTIMEare 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 viaI_FXEN; gain zeroed viaD_VOL; pan/width/pan-law/mode set to unity viaD_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. NoFxBypassGuardneeded 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/abortdriven byOnTimer, non-blocking — REAPER's UI stays responsive across the record).begin()validates, snapshots all state, creates the temp track, routes the tap, arms, callsCSurf_OnRecord, and returns immediately.tick()(called fromOnTimer) reads the transport viaGetPlayStateEx/GetPlayPositionExscoped to the record's ownReaProject*(project-switch safe), advances the pureadvanceRecordPhasestate machine, and on a terminal verdict stops + finalizes/restores.abort()is the force-terminate path for shutdown and project switch. RealtimeCaptureStatesnapshot + idempotent restore: snapshots cursor, time selection, and every other track'sI_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-scopedOnStopButtonEx(proj_), never the globalCSurf_OnStop), project close (guarded byValidatePtr2(nullptr, proj_, "ReaProject*")— a closed project callsdropWithoutRestore()rather than touching freed pointers), and extension unload.Finalizingflush-wait before file move: after the transport stops,tick()waits for the recorded file size to be positive and stable across a tick before callingfinalizeRecording(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:
recordedFilePathdiscovers the take's source file from the temp track's first media item;finalizeRecordingmoves it into the bank folder (cross-volume fallback: copy+remove); populates aSampleviasampleFromRecordedCapture; clean teardown viarestore()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
CreateTrackSenddefaults to post-fader (I_SENDMODE=0) with full-stereo (I_SRCCHANdefault): 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 andFxBypassGuard(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 onValidatePtr2(nullptr, proj_, "ReaProject*"). A closed project already reclaimed its temp track, arms, and transport —dropWithoutRestore()latchesrestored_and clearstemp_/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 constantskAutoTrimThresholdDb(-72) + derivedRENDER_TRIMENDamplitude ratio viaautoTrimEndRatio()(≈ 0.00025119 for -72 dB, computed as10^(dB/20)—std::powis notconstexprbefore C++26 so this is a function, not a constant),kMaxTailSeconds/kMaxTailMs(8 s);TailMode { None, Auto, Manual }enum;TailRenderSettingsstruct (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) viaGetSetProjectInfo;ScopedRenderSettingssnapshots and restoresRENDER_TRIMENDalongside the existingRENDER_*set.RENDER_TAILFLAG = kTailFlagCustomBounds(1) for Auto and Manual — custom bounds is the always-applicable tail bit for offline captures. CaptureRequestthree-state tail contract (None/Auto/Manual(ms)); default None (exact bounds, null-test-safe). The earlierrenderTailbool/tailMspair was superseded.- Exposure: a docked-panel footer toggle (label "Tail: Off" / "Tail: Auto" /
"Tail: Manual", cycles on click via
cycleTailMode) inbank_panel.cpp, backed by the puretail_controlmodule (TailSetting,cycleTailMode,clampManualMs,tailToggleLabel— unit-tested). DefaultTailMode::None.CAPTURE_ITEMandCAPTURE_TRACKread 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_TRIMENDamplitude curve (0.00025119 ≈ -72 dB); trim-end-only normalize (32768) does not engage fades/normalize/pad; trim never eats pre-ENDPOSbody. (See spec §Open questions / DAW-confirm.)
Notes/decisions:
- Surgical normalize.
kNormalizeTrimEnd = 32768sets 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_controlpure 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 incapture_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 bendingcomputeEnvelope.) - 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 onpeaksfor theAudioSamplefloat alias. Unit-tested via a newwav_trim_testsCTest target. peaksgainedlastFrameAboveThreshold(backward PCM scan, per-frame max-abs across channels, no fold) for the Auto decay scan.- Auto/Manual/Off semantics. Auto: records
[start, end + 8 s cap], scans backward for the last frame above -72 dBFS, truncates the WAV header-aware at that frame. Manual: records[start, end + fixed tail], skips the scan. Off: byte-identical to the pre-tail exact-bounds capture. - Realtime tail is non-deterministic by design (inherent to the realtime backend). Bit-identical repeats are not asserted for the realtime path; this is documented, not a defect.
T1-followons — Manual fine-adjust UI + per-project tail persistence
Goal: Close the two follow-ons deferred at T1 landing: (1) scroll-wheel fine-adjust
of the Manual tail length in the panel footer; (2) the tail setting (mode + Manual length)
persists per-project inside the .rpp rather than resetting on extension unload.
Verify (in DAW): Scroll-wheel over the footer adjusts Manual length in 250 ms steps,
clamped 0–8 s; the label reads "Tail: Manual X.Xs" (one decimal) in Manual mode; footer
click still cycles Off → Auto → Manual. The tail setting survives Save / close+reopen;
projects with no stored key fall back to Off / 2 s.
Depends on: T1.
adjustManualMs(current, notches, stepMs)pure helper intail_control(per-notch ±kManualStepMs= 250 ms, clamped [0,kMaxTailMs]); unit-tested.tailToggleLabelupdated: 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
adjustManualMsand repaints; click handler unchanged (still cycles mode viacycleTailMode). serializeTailSetting/deserializeTailSettingpure round-trip (mode + manualMs) added totail_control; unit-tested includingstd::nullopton malformed input.TailSetting tail_promoted intoReaSamplerSession(peer tobank_andview_);persistserializes 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,ssuffix) — 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
planLaneMintingdecision (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 oneTrackSplit, oneLaneMintper involved mode (durable key =laneNameForMode(mode), owned by that mode), and oneLaneAssignper 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
applyMintPlaninview.cpp: enablesI_FREEMODE= fixed lanes, growsI_NUMFIXEDLANES(never shrinks — user's manual lanes are never deleted), stamps each managed lane's durable name viaP_LANENAME, records ownership in the model (lanes().setManaged), assigns each item to its mode's lane viaI_FIXEDLANEresolved from the durable key. Returnschangedso 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:
planTogglelane ops applied viaapplyLaneOpsso the freshly-minted lanes take the correctC_LANEPLAYSstate for the active mode without a fullapplyModere-run (which would re-park/restore whole tracks — not correct for a minting tick). mintManagedLanesentry point inview.cpp: reads live track/item picture viareadLaneTracks, callsplanLaneMinting, wraps the apply in one Undo block labelled"ReaSampler: separate cross-mode content into lanes", callsUpdateTimeline()+UpdateArrange()after a fixed-lane mode change.reconcileManagedLanesinview.cpp: on project load, reads every fixed-lane track'sP_LANENAMEvalues; 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 frommain.cpp's load path beforeapplyMode.- Lane-ownership index persists via
ViewModeModel::serialize()/deserialize()— theLaneOwnershipIndexis a member ofViewModeModeland round-trips inside the"reasampler"view_statekey alongside modes, membership, snapshots, and active mode. No new persistence key required. - Detection tick integration:
mintManagedLanesis called from thebank_paneltimer 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:
planLaneMintingnever receives manual-lane items as split candidates. The shell'sreadLaneTracksmarks items on manual lanesonManualLane = 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 redundantI_NUMFIXEDLANESre-read: single grow-and-track pass removes the secondGetMediaTrackInfo_Valuecall inside the mint loop. - W3-A polish — extracted shared item-read seam (
src/item_read.{h,cpp}): removes duplicateditemGuid/itemLaneNameread logic fromview.cppandbank_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) alongsideM/D/B/R— a distinct concern (build identity + channel isolation) that touches CMake,main.cpp's forever-stable command-id contract, and the"reasampler"ext-state. Product framing + full option analysis:docs/product/versioning-and-release.md. Deploy/CD wiring (two named artifacts per platform) hands off to dev-ops.
V1/V3 — app_version module: version constant, ext-state stamp, show-version action
Goal: Pure app_version module — single-source semver from CMake
REASAMPLER_VERSION "0.9.01" via configure_file → version_generated.h;
ext-state writing-version stamp under key "version" riding
saveToActiveProject(); absent stamp = silent pre-versioning; on-demand
"ReaSampler: show version" action (no startup print). New CTest target
app_version_tests.
Verify: CTest green. Stamp written under "version" key on every
saveToActiveProject() call. Absent key classifies as PreVersioning (silent).
Show-version action fires on demand only.
app_versionpure 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_filewiresREASAMPLER_VERSION(the one CMake variable) +REASAMPLER_CHANNEL_IS_BETAintoversion_generated.hin the build tree;app_versionreads from there — one edit re-threads the version string through every consumer.- Writing-version stamp:
persistcallsSetProjExtStateunderkProjExtVersionKey("version") withstampVersion()insidesaveToActiveProject()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-sourcedappVersion()string to the console when fired. No unconditional startup print (no version line added to the extension load message). app_version_testsCTest target: version parse/compare/classify round-trip;PreVersioningon empty;Unknownon malformed;Stampedon well-formed;versionLessnumeric 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_versionextended as the single source of truth for channel identity (V4):channel(),isBeta(),extStateNamespace(),commandIdPrefix(),actionDisplayPrefix(),binaryName(),dockTitle(),dockIdent()— all derived from the oneREASAMPLER_CHANNEL_IS_BETAbit. 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_filethreadsREASAMPLER_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 viachannelCommandId/channelActionNameand readextStateNamespace()— no scattered#ifdefforks 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-betasuffix in the stamp. The channel is carried by the isolated namespace (extStateNamespace()), not baked into the stamp, so the stamp parses asStampedon read-back and stable's stamp is byte-identical regardless of channel build. - Two permanent commitments accepted: a second forever-stable command-id prefix
(
CEREBELLUM_REASAMPLER_BETA_) and a second ext-state namespace ("reasampler_beta"). Beta keybindings are a distinct forever-family from stable's. - Isolation semantics (accepted, not a bug): a channel reads/writes only its own namespace — a stable project looks empty/default when opened in beta, and vice versa. No cross-namespace read, migration, or fallback.
- Deploy implication (dev-ops hand-off): two named artifacts per platform
(
reaper_reasampler+reaper_reasampler_beta), built by toggling-DREASAMPLER_CHANNEL.
Phase B — Multi-bank (parallel to the M0–M11 capture roadmap and Phase D)
Separate phase namespace. The M-numbers belong to the capture pillar (M0–M11); the D-letters belong to Design View. Multi-bank is a third orthogonal pillar — generalizing the single bank into a pool + named banks — so it takes its own lettered namespace (B1, B2, …). "B" reads for Banks and, like Phase D, keeps the roadmaps from colliding on numbering: Phase B is not "the twelfth capture step," it is a different pillar. Authoritative spec: CONTEXT.md §Multi-bank. Product framing:
docs/product/multi-bank.md.
B1 — bank_book (pure)
Goal: REAPER-free bank registry wrapping N BankIndex instances: pool seeded +
privileged, create/rename/reorder/delete named banks, active-bank id, move/copy a
sample between banks, JSON round-trip + legacy-migration. The heart of the phase;
mirror of bank_model / view_mode_model; BankIndex untouched (additive).
CONTEXT.md §Multi-bank (Module architecture — pure).
Verify: CTest green. Pool always present, un-deletable, un-renamable,
un-evacuable (rules rejected in-model). Active-bank defaults to pool. Move is
index-only (source loses entry, destination gains it) and observes destination
collapse-by-hash; copy leaves source intact. Delete drops member index entries.
Evacuate moves all members to the pool, leaving the bank empty. JSON round-trip
lossless across pool-as-bank-zero + named banks + per-bank indices + ordinals +
active id. Legacy bank_index JSON parses into { pool } with zero named banks.
- 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_indexJSON 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 B1–B5 (create/rename/reorder/delete-bank, move, copy, evacuate, remove) wraps its bank/index mutation in a batched REAPER undo point (
Undo_BeginBlock/Undo_EndBlock), so one bank operation is one Ctrl-Z. This is a cross-cutting decision that retro-touches B1–B4, not a B5-local one; the per-verb points above inherit it. Must-verify before build: confirm againstvendor/reaper-sdkthat"reasampler"ext-state mutations participate correctly inUndo_BeginBlock/Undo_EndBlockundo 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 viaUNDO_STATE_MISCCFG("extensions!"), SDK-verified. Aprojectconfighook (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 (siblingowned_fileskey — persistence shape resolved at build time: sibling key, not folded into thebanksblob). - 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_manifestmodule (src/owned_manifest.{h,cpp}): relative paths, dedup, JSON round-trip. Deliberately decoupled frombank_book— tracks files created, not index membership; sample-remove is not manifest-remove. Unit-tested via newowned_manifest_testsCTest 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
bankskey (pool-as-bank-zero inside the blob; distinct section fromview_state; nobank_indexkey written going forward). - Legacy-migration path on load: absent
banks+ presentbank_index→ promote into pool, mint the blob, treat blob as authoritative (legacy key retired). - Session exposes the book; the active bank's
BankIndexis 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 viatab_strip_testsCTest target. Mirror ofmode_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::removethroughbank_book: remove aSamplefrom a bank's index; pool contents removable, pool-container privileges unchanged. - "Remove selected sample(s)" action (
command_id/gaccel/hookcommand), MIDI-bindable; carries ascope: this-bank | all-banksseam (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_panelremove 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-banksseam stays in the action signature but all-banks is a latent parameter, not a surfaced verb. hashReferencedElsewherecross-bank reference query inbank_bookretained as a tested model API for Phase R prune; the remove shells no longer call it.- Both
bank_panelcontext 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. TheSample.provenancestruct 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.provenanceon resample-from-sample:parentSampleId(the bank sample the capture derived from) +fxChainSnapshotas 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 (
FxBypassGuardsnapshot/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
provenancemodule:rsprov1length-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 viaTrackFX_*/TakeFX_*; source-item path collection; parent-detection inputs. Item scope fingerprints take FX viaTakeFX_*; track scope fingerprints track FX. StampsSample.provenancewhen 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) alongsideM/D/Bbecause 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}: exportspruneOrphans(present, referenced, owned)(the safety-critical set algebra),buildPruneReport(count/bytes/display-capped list, unit-testable), andpruneDeletePlan(the R3 confirm-time staleness intersection —confirmed ∩ freshOrphansin 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 tobank_book. Newprune_reconcile_testsCTest 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, unioningbook().referencedPaths()andowned().paths(), feedspruneOrphans, and callsbuildPruneReportwith a 64-file display cap. PurebankRelativeForName(capture_paths) normalizes the folder- enumeration spelling to match the index convention so the pure core's exact-string match lines up. Forever-stableBANK_PRUNE_FOLDERaction 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_panelprune 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 ofmode_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
ShowMessageBoxconfirm (count+bytes+files) →pruneDeletePlanstaleness 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 tounlinkbehind 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
presenton the next prune scan, andpruneOrphansreturns(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 ofmode_switch/tab_strip. Newprune_button_testsCTest target. bank_panelfooter button dispatchesBANK_PRUNE_FOLDERviaMain_OnCommandthrough 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_capturepure module +CAPTURE_BATCH_ITEMS/CAPTURE_BATCH_RAZORactions): 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.RunCaptureinternals extracted to a sharedcaptureAndIndexOnehelper (behavior identical). New CTest targetbatch_capture_tests. - Action trigger buttons + keybinding help labels (
action_buttonspure module + bank_panel strip): strip layout/hit-test with min-width overflow-hiding and label formatting with "(unbound)" fallback; structActionButtonRect; 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 viaNamedCommandLookup+Main_OnCommand; labels show live bindings viakbd_getTextFromCmd. Coexists with Phase R's prune button (footer). New CTest targetaction_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_outpure 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 targetdrag_out_tests.drag_out_winshell (Windows): OLEDoDragDrop/CF_HDROP. Copy-only structurally —DROPEFFECT_MOVEis not offered and no source-deletion path exists; prune remains the sole file-deleter. macOS/Linux viaSWELL_InitiateDragDropOfFileListwith a documented copy-semantics caveat (SWELL does not expose a drop-effect query).bank_paneladditive hook only — internal move/copy drag unchanged.
Notes/decisions:
- Copy-only is structural, not a policy flag:
DROPEFFECT_MOVEis 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.banksblob) resolved at build time: sibling"owned_files"key. - Fork R-E — trigger. Settled: manual action +
bank_panelbutton, 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/primarypastel lime#B0E098= live/active/selected,accent/secondarypastel teal#84D6D0+accent/tertiarypastel purple#C2AAE8= categorical distinctions);Roleenum carries the three accent roles;roleColor/roleColorStateupdated (Active/Dragging/ Focus → primary accent);spectralColoris 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 targettheme_tests.component_geometrymodule (src/component_geometry.{h,cpp}): button/slider/ list-row geometry + hover hit-test. Pure; no LICE or REAPER types. New CTest targetcomponent_geometry_tests.draw_kitshell (src/draw_kit.{h,cpp}): LICE draw layer —fillSurface(micro-gradient + inner highlight/shadow),drawButton/drawSlider/drawListRow/drawWaveform, cached-fonttext()over fourLICE_CachedFonts (kit-owned lifecycle), full interaction-state model, double-buffer preserved.- GDI
DrawTextretirement inbank_panel: all panel text now routes through the kit's cached-fonttext(); raw GDIDrawTextpath 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_barpure module (src/action_bar.{h,cpp},tests/test_action_bar.cpp, CTest targetaction_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 ofmode_switch/bank_grid/action_buttons.bank_panelredesigned: 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; singleKitColor→LICE_pixelboundary via the kit'stoLice(now exposed indraw_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_barrow 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_barActionCluster::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 · … · Prunewarnset apart at the right); pure footer-strip layout in newfooter_barmodule 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 targetfooter_bar_tests. action_bargainedActionCluster::TaggingandActionCluster::Switchingfor 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 +
TrackPopupMenupopup); each entry fires its existing command id. Pure layout owns the menu-button rect + hit-test (overflow_menupure 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 (
tooltippure module): sourced from the registered action phrase (notkbd_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 fromkbd_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_enablepure module) unit-tested; shell readsview().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:
clusterGap16→24 (buttonGapremains 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 targetsoverflow_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(andbindingHeight/minSplitHeightremoved fromActionBarSpec); 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 viakbd_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.
SlotMapinbank_book: gap-preserving per-Bankid→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 viareconcileSlots()on the load path.bank_model/Sampleuntouched by position (position is a per-bank display concern).BankBookmutators: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.Samplemeter stamp (ONE sanctionedSamplechange):captureTimeSigNum/captureTimeSigDenomadded toSample+ JSON round-trip; stamped on both capture paths and refreshed on re-capture viaTimeMap_GetTimeSigAtTime(API confirmed at build). Old samples with no stamp fall back gracefully (blank musical read-out).card_dragpure 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. SparsecomputeSlotRects/hitTestSlot. SWELL stock cursors chosen at build: Reorder→IDC_SIZEALL, Move→IDC_HAND, Copy→IDC_UPARROW, Replace→IDC_SIZEWE. New CTest targetcard_drag_tests.card_metapure module: bars.beats.subdivisions from the stamped tempo + meter; seconds.milliseconds rounded. Both blank when the sample is unstamped. New CTest targetcard_meta_tests.bank_panelsparse-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_paneldrop 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 viaSetCursorper the pure resolved gesture.bank_panelmetadata overlay: bars.beats bottom-left (Micro), s.ms bottom-right (ValueMono),TextDim, decorative / non-interactive.- Selection restyle: normal cell +
accent/tertiarypurple 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_GetTimeSigAtTimeconfirmed at build for the meter stamp; SWELL stock cursors chosen (no custom cursor load/synthesis required); gap navigation = skip gaps (arrow keys skip empty slots); same-slot reorder = no-op. - New CTest targets
card_drag_tests,card_meta_tests.
L3 — VST editor + embed-strip restyle
Goal: Bring the ReaSampler 9000 VST editor (IPlugView LICE surface,
reasampler_editor.cpp) and the S6 embed strip (reasampler_embed.cpp) up to the
settled-and-revised B + three-accent pastel look via the L1 kit: kit cached-font
text() (the kit's current face — no font change), kit component draws, the
REAPER-grey neutrals (bg/base #2b2b2b / bg/panel #333333 / bg/cell #3a3a3a)
with the three pastel accents (primary lime / secondary teal / tertiary purple), the
pastel spectral keyboard strip + zone bars (active zone lifts to accent/primary +
a static glow) as the signature surface, and hover/pressed/drag interaction states
throughout. Merged as c53683e. L3 was the last remaining Phase L point — Phase L
is now complete (L1, L2, L3, L4, L5, L6, L7 all landed).
Verify (in DAW): the VST editor + embed strip render in the settled B + three-accent
pastel language through the L1 kit — kit AA cached-font text, gradient/rounded kit
components, the pastel spectral keyboard strip, working hover/pressed/drag; the VST3
class UID is unchanged (a visual refresh is not a compat event).
- Route the VST editor's + embed strip's text through the kit's cached-font
text(); retire their raw GDIDrawTextApath (retired in both shells). - Retire the shells' local pre-L1 palette — the
kColBackground/kColCardBg/kColThumb/… forest-green-on-charcoal constants block inreasampler_editor.cpp(and the mirrored constants inreasampler_embed.cpp) — and draw every surface through the L1themeroles. One kit, one look; two palettes collapsed to one. - Restyle the editor + embed components through the kit (capture-first browser search/tabs/thumbnails, channel toggles, ADSR + pitch sliders, Varispeed/Preserve mode toggles, zone bars, list rows, waveform + start/loop markers, segmented controls) in the B (Neon Console) palette with hover/pressed/drag states throughout.
- Apply Direction C's pastel spectral treatment to the keyboard strip + zone bars:
hue-mapped zones as a pastel sweep anchored on the three accents; active zone lifts to
accent/primary+ a static glow (no animation); waveform + loop/start markers drawn throughdrawWaveform+warn/accent marker roles; VST3 class UID unchanged.
Notes/decisions:
- Full restyle, not a born-in-kit no-op. The Phase S surfaces (
reasampler_editor.cppreasampler_embed.cpp) arrived on dev drawing flatLICE_FillRectblocks + raw GDIDrawTextA, off a local pre-L1 forest-green palette (kColBackgroundetc.) — the coordination contract's "born in the kit" branch did not occur. L3 performed the full restyle and reconciled the two palettes into one.
- Shell-side only. All
src/vst/UI geometry modules (editor_geometry,keyboard_strip,waveform_view,capture_browser,param_slider,browser_scroll,embed_strip) are pure geometry/hit-test — zero LICE, zero draw. L3 touched only the two draw shells; no geometry rework was needed. - Beta title band — textual-only distinction (Daniel, 2026-07-27). The S18 beta
channel title band gets no distinct visual accent; L3 restyles it in the standard B
pastel palette and the beta-vs-stable distinction stays purely textual (the
channel-derived plugin name via
app_version, as before). No channel-specific accent color. Closes the one open fork from the L3 readiness review. - VST3 class UID unchanged. A visual refresh is not a compat event; RT/
processpath untouched; pure geometry modules remained pure throughout.
Phase S — MIDI-playback instrument (native VST3 sampler; a second build artifact)
Landed on dev (merged 2026-07-27); DAW verification pending Daniel's smoke test. S1–S18 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
processmarshalling: 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 withpersist, not re-implemented — the parent-of-.rpp derivation is extracted tocapture_paths::projectDirOfRpp, which bothpersistand the bridge call). Bank JSON parsed via the sharedbank_bookpath (the spike string-scan reader retired); ext-state key names shared via pureext_keys.h. - Sample selection UI (minimal, in the
IPlugViewLICE 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
processvia a lock-free atomic pointer swap (graveyard-reclaim);processnever 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
IPlugViewLICE 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
IReaperUIEmbedInterfaceon 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):
SampleDatacarries 1- or 2-channel decoded PCM (frames+ optional length-matchedframesR;channelCount());Voice::renderFrameStereo+ aVoiceEngine::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 (renderFramereads 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 indecodeChannels: 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:
decodeRelativefills 1- or 2-channelDecodedZonePcmper 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:
setBusArrangementsaccepts only the mode's arrangement (kMono/kStereo), else rejects (kResultFalse) but keeps a valid mode arrangement sogetBusArrangement(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.hcontract,vstsinglecomponenteffect.cppbase impl,ivsteditcontroller.hkIoChanged); see handoff notes.
- calls
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_LoopTimeRangeas the capture inputs), add the resultingSampleto 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: confirmMediaExplorerGetLastPlayedFileInforeturns a usable path+range for a merely-selected (not-yet-played) file, or whether a play is required first. - Drag-and-drop onto ReaSampler surfaces: 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/ anIDropTargeton 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 theIPlugViewHWND can accept an OS file drop and relay it to the extension as a bank-ingest request (the instrument does not ingest; it forwards a request 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 (newext_keys.hconstant — 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 existingreloadFromBank()on change — reusing S4's atomic pointer-swap handoff so a refresh mid-play does not glitch. No new audio-thread work; no allocation inprocess. - 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
selectSamplefirst-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
SampleChoiceto carry the peak thumbnail data (from theSamplepeaks bank_model already stores — the same peaks the dock panel draws), the root/key badge (S2rootNoteintrinsic / the optional musical key), and its bank. A new purecapture_browsermodule 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_stripgeometry module (serves the single-capture case first): map a MIDI key span across a strip width (128 keys → pixels, reusing the S6embed_stripkey-span idiom); a root marker for the loaded capture;pixel→noteand akeyAtPointfor 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 ofmode_switch/editor_geometry. - Editor shell drag-state machine:
WM_LBUTTONDOWNgrabs a card / a key / a zone edge-or-body,WM_MOUSEMOVEupdates the in-flight edit against the pure resolver,WM_LBUTTONUPcommits via the existingcommitMapAndReload(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↔pixelmapping across the waveform rect, marker x-position from a frame index, marker grab regions (start/end/loop-start/loop-end), drag-delta(grabbedMarker, dxPixels) → newFramewith 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
peaksmodule 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). ExtendsPerformanceZonewith an optional loop override (additive, same shape asrootOverride) + 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_browsermodule 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_browsermodule; filter/scroll state is transient UI state. Landed: purebrowser_scrollmodule (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 intorebuildVisible). 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
commitAndReloadlike every other edit. Landed: purenote_entrymodule (note_entry_tests) —parseNoteEntryaccepts 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 throughcommitAndReload. - 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'sZonePlayParams(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 viacommitAndReload. Landed: pureparam_slidermodule (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/IDropTargeton the editor child HWND (the same SWELL/Win32 surfacebank_panelowns), 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 callsDragAcceptFiles(TRUE)on attach and handlesWM_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 mode — Gate (classic held note) or
Trigger (one-shot) — a per-sample/per-zone performance choice (D-B, instrument-owned).
Gate is today's behavior grown from ADSR to AHDSR (adds a Hold stage): note-on →
attack/hold/decay/sustain, note-off → release, sustain loop points apply (S11's
draggable loop UI is Gate-mode UI). Trigger is a one-shot drum-pad: note-on fires
playback of a defined % of sample length with a fade-in and fade-out ramp,
ignores note-off, and uses no sustain loop. Both modes carry a modifiable
start point (playback begins at an offset into the sample, not always frame 0). This is
an S3-core extension (the engine Daniel smoke-tests) plus editor surfacing — the mode +
its parameters are instrument performance-map state, never a bank fact. CONTEXT.md §Phase S
(Sampling modes — Trigger vs Gate). Daniel's feature set is settled.
Verify (in DAW): a sample in Gate mode plays held with the AHDSR envelope (hold
stage audible between attack and decay), releases on note-off, and loops its sustain region
if loop points are set; a sample in Trigger mode fires a fixed % of its length on
note-on with audible fade-in/out, plays through to completion regardless of note-off,
and never sustain-loops; the start point offsets playback in both modes (a note starts
partway into the sample); the mode + parameters are per-instance component state that
survive save/reopen; the pure core's Trigger envelope (fade-in → hold → fade-out over
%-length frames) and the AHDSR hold stage are asserted against known signals; existing
Gate/ADSR behavior is unchanged when hold=0 (regression).
Depends on: S3 (extends the envelope + voice read-position machinery), S5 (the
PerformanceZone the mode + params attach to), S11 (Gate loop-point UI; Trigger's waveform
shows start + %-length + fades on the same waveform surface). Independent of S7.
- Core:
PlayMode { Gate, Trigger }on the voice + the envelope split. Gate growsAdsrParams→AhdsrParams(addholdFramesbetween attack and decay; hold=0 is the exact current ADSR — back-compat). Trigger is a distinct envelope: play[start, start + lengthFraction·(frames−start))with a fade-in ramp (0→1 overfadeInFrames) and a fade-out ramp (1→0 overfadeOutFramesending at the play-length end), ignoring note-off (release is a no-op in Trigger). Fade curve default equal-power (constant-powersin/cos, click-free on one-shots); pure, unit-tested against a known signal. - Core: modifiable start point — the voice's initial
readPos_isstartFrame(frame offset), applied in both modes; the existing per-framereadPos_ += ratio_read and loop/interp machinery is otherwise unchanged. Clamp0 ≤ startFrame < frames. - Core: % length → frames + fade mapping for Trigger.
lengthFraction ∈ (0,1]resolves toplayEnd = start + round(lengthFraction·(frames − start));fadeInFrames/fadeOutFramesclamp so their sum ≤ play length (fade-out anchored toplayEnd). Note-off in Trigger does nothing; the voice frees whenreadPos_ ≥ 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/zone —
PitchEngine { Varispeed, Preserve }. Varispeed =readPos_ += ratio_(today's path, pitch and duration coupled). Preserve = duration-preserving: the read advances at the source rate while a pitch shifter transposes the output. Mode is per-PerformanceZoneperformance 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_shiftmodule (house pattern — CTest-testable, no WDL/REAPER/VST3 type at the boundary). Pre-allocated, no locks, noprocessallocation; pre-warmed at voice allocation.pitch_shift_testsCTest target. WDL_SimplePitchShifter excluded by include-chain (windows.h); held as a quality/latency swap alternative (PitchEngine::Preservecontract 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 multipliesratio_; 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_shiftmodule chosen overWDL_SimplePitchShifter(excluded by include-chain). Both share the samePitchEngine::Preservecontract;WDL_SimplePitchShifteris held as the quality/latency swap (the HELD item inPLAN.md).
S17 — drop-and-load: drag a capture onto a track's FX button → instantiate ReaSampler 9000 with the capture loaded
Goal: Turn a bank capture into a playable instrument in one gesture. While a capture
is dragged from the bank_panel, a track's TCP FX button lights as a drop zone,
and dropping there instantiates a ReaSampler 9000 on that track with the dragged
capture already loaded and selected for playback. A third DragGesture —
InstrumentDrop — added to the drag_out pure module. Mechanism: TrackFX_AddByName +
VST3 component-state injection via TrackFX_SetNamedConfigParm(..., "vst_chunk", blob).
The shared instrument_drop pure module constructs the blob via the instrument's own
sample_map::serializeComponentState/deserializeComponentState — one serializer called
from both artifacts, so the blob format cannot drift. CONTEXT.md §Phase S (drop-and-load).
Verify (in DAW): dragging a single capture from the dock over a track's FX button
highlights it; dropping instantiates ReaSampler 9000 on that track with the dragged
capture loaded, selected, and MIDI-playable immediately; OS drag-out to Explorer still works
unchanged; internal bank-to-bank drag still works unchanged; no media item ever inserted
into the arrange. DAW-verify: whether REAPER's vst_chunk write-parm expects the plugin's
raw IComponent-state bytes or wraps them in a REAPER container header.
Depends on: M11 (drag_out gesture machinery), Phase S S4, the load-capture seam
(VST3 component-state injection — the shared blob contract).
- Extend the
drag_outpure module with the third gesture:DragGesture::InstrumentDropwhen a drag armed with a single capture is over REAPER's UI outside the panel client rect;OsDragonly when it has left REAPER entirely;Internal/OsDrag/Noneotherwise unchanged.DragStategains 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 — onlysingleCapturearms InstrumentDrop; multi-payload over REAPER UI falls through toOsDrag. - 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::resolveFxDropTargetimplements this. Landed insrc/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/getStatealready round-trip the fullComponentStateviasample_map::serializeComponentState/deserializeComponentState(S10). The extension REUSES that exact serializer through the new pureinstrument_dropmodule (buildInstrumentDropChunk→serializeComponentState→ base64). The shared-writer requirement is met STRUCTURALLY — the blob format cannot drift. Pure round-trip test decodes back through the instrument's own reader and asserts the capture is selected.instrument_droplives atsrc/instrument_drop.{h,cpp}(extension-side pure module); CTest targetinstrument_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.cppadds the InstrumentDrop cases with M11 cases retained as the non-regression guard;test_instrument_drop.cppis 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 inbank_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 inreasampler_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 targetOUTPUT_NAMEforks by channel —reasampler_9000(stable) /reasampler_9000_beta(beta) — viaREASAMPLER_VST_OUTPUT_NAME. Display name: factoryDEF_CLASS2plug-in display string sourced fromapp_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
-betarender (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.