Batch capture + panel action buttons/labels landed; conform-on-insert verified already shipped; resample-and-mute-source cut by Daniel (Design View park/hide supersedes it). Drag-out remains in PLAN.
77 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)").
Remaining in M11: Native OS drag-out (not built in wave 1 — still in PLAN.md).
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).