57 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 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).- Confirm-on-last-reference guardrail: remove that orphans a file (no other bank references it) confirms, naming the orphaned-until-prune consequence; remove of a still-referenced sample does not confirm.
- 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 added tobank_bookto support the confirm-on-last-reference guardrail without coupling to the panel.- Both
bank_panelcontext menu ("Remove selected sample(s)") and Delete key affordance wired; panel gesture is also one batched undo point (R-B applies).
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.