Files
reasampler/COMPLETED.md
T
daniel 960c5166ee docs: record D2-W3-A landed in COMPLETED.md
planLaneMinting pure decision, applyMintPlan shell, reconcileManagedLanes
on load, persist via ViewModeModel view_state. CTest 14/14; DAW verify
pending. (PLAN.md D2-W3-B narrowing already landed via a concurrent commit.)
2026-07-23 20:24:31 -04:00

41 KiB
Raw Blame History

COMPLETED.md — ReaSampler landed milestones

Completed milestone entries removed from PLAN.md. Each entry preserves its original Goal, Verify, and checklist points with boxes marked done.


Milestone 0 — Transition scaffold: reaper_mpeview → ReaSampler

Goal: Retire the MPE scaffold and stand up the sampler's pure core in its place, preserving the pure-core / REAPER-shell split. Verify: cmake -B build -S . configures clean; cmake --build build builds the renamed extension target and the pure-core test target; ctest --test-dir build is green with the new bank_model + peaks suites present.

  • Delete src/mpe_model.{h,cpp} and src/mpe_view.{h,cpp}; remove tests/test_mpe_model.cpp.
  • Rename the CMake project() and the extension MODULE target from reaper_mpeview to reaper_reasampler (binary OUTPUT_NAME likewise); update PREFIX "" / platform SUFFIX blocks to the new target name.
  • Replace the pure mpe_model static lib + mpe_model_tests executable with bank_model (pure static lib) + bank_model_tests; keep the CTest wiring.
  • Repoint src/main.cpp: drop the mpe_view.h include and all MpeView_* calls (toggle / IsOpen / OnTimer / Cleanup); stub the extension entry so it loads, logs to console, and registers nothing MPE-specific. The command_id / gaccel / hookcommand registration pattern is preserved for reuse (CLAUDE.md §REAPER extension contract) — the MPE action string is removed.
  • Choose and record the persistent action-id prefix for the sampler family (replaces CEREBELLUM_MPEVIEW_TOGGLE); this string is forever-stable once shipped (CLAUDE.md §action registration).
  • Refresh README.md layout/next-step sections to the sampler module set. (Landed-work reflection is doc-keeper's; this point exists so the stale MPE README does not mislead the first implementer.)

Milestone 1 — bank_model + JSON round-trip (pure)

Goal: The Sample metadata struct and BankIndex (add / remove / query / tier moves / dedup-by-hash) with JSON serialize/deserialize to std::string. CONTEXT.md §Data model, §Module architecture. Verify: CTest green. Round-trip is lossless (deserialize(serialize(x)) == x) across all fields; dedup-by-hash and tier filtering asserted; relative paths only invariant enforced at the model boundary (no absolute path accepted/stored).

  • Define Sample with the full field set (id, display name, relative path, source mode, source range in project time + PPQ, track GUID(s), wet/dry, channels, SR, length sec + beats, capture tempo, optional key, peak/RMS/LUFS, clip flag, tier, content hash, provenance, created ts). CONTEXT.md §Data model.
  • BankIndex: ordered collection keyed by id; add / remove / query.
  • Hash lookup for dedup-by-content-hash.
  • Tier model (scratch | archive) + tier-move + tier filtering; scratch marked auto-prunable.
  • JSON serialize/deserialize to/from std::string.
  • Tests: full-field round-trip lossless; dedup collapses equal-hash adds; tier filter/move correct; relative-path invariant rejects absolute paths; empty-index and malformed-JSON edge cases.

Milestone 2 — peaks (pure)

Goal: Compute waveform min/max bins from raw PCM, dependency-free (not REAPER's peak API). CONTEXT.md §Module architecture, §Non-goals. Verify: CTest green. Fed a known signal (full-scale sine, ramp), asserted min/max envelope per bin matches expected within tolerance; channel count preserved; bin count honored for arbitrary sample lengths (incl. remainder bin).

  • Min/max bin computation from interleaved PCM given a target bin count.
  • Multi-channel handling (per-channel envelope; no silent fold).
  • Tests: sine envelope ≈ ±amplitude; ramp envelope monotonic; DC/silence → zero envelope; short-buffer and non-divisible-length edge cases.

Milestone 3 — Offline capture spike (REAPER shell)

Goal: Offline-render the time-selection master mix to a wav in the project bank folder, add a Sample, log it. The render-driving spike. CONTEXT.md §REAPER API surface (offline render), Build order 3. Verify (in DAW): Render runs via Main_OnCommand(42230) ("Render using most recent settings") — REAPER always shows its offline-render progress window; no stock/header-documented fully-headless path exists. File lands in the project-relative bank folder at 32-bit float WAV at project rate (lossless, dither-free → enables bit-identical/null-test). A Sample is added to the in-memory BankIndex. Non-destructive. Unsaved-project state triggers a Save-As prompt; capture is refused if the user cancels (no default-location fallback). Bit-identical repeats: two identical requests produce byte-identical files. Exact bounds: rendered length matches the requested range (no rounding, no added silence without an explicit tail).

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

Milestone 4 — persist (index ↔ project ext state)

Goal: Write the BankIndex JSON to project ext state, reload on project open; project-relative path resolution. CONTEXT.md §persist, §Persistence & paths. Verify (in DAW): Index survives Save / Save As / close+reopen; bank travels with the .rpp; relative paths only in the persisted index (Save As to a new folder still resolves the bank).

  • SetProjExtState / GetProjExtState under namespace "reasampler".
  • Bank-folder resolution from the current project path (EnumProjects / GetProjectPathEx); store under a project-relative subfolder.
  • Reload-on-open; confirm survival across Save / Save As.

Notes/decisions:

  • Storage: SetProjExtState / GetProjExtState, namespace "reasampler", keys bank_index (serialized JSON) and project_guid; relative paths only in the persisted index.
  • Project identity: keyed off a minted GUID stored in ext state (REAPER exposes no native per-project GUID), not the raw ReaProject* — a recycled pointer cannot misread a project switch as a Save-As.
  • Save-As: copy semantics (Daniel's decision) — the reasampler_bank/ folder is copied under the new .rpp; the old project's bank stays intact. Every ext-state write calls MarkProjectDirty so captures/GUID changes flush on the normal save.
  • Save-As collision — fixed (DAW-verified): project identity is GUID-primary — the stored per-project GUID is the identity of record; a different stored GUID always means a different project (Load its bank), immune to REAPER recycling ReaProject* addresses across close/open. The ReaProject* pointer is a secondary signal that disambiguates the same-GUID case only: a different object with the same GUID = a Save-As fork (Load + re-GUID to diverge); the same object with the same GUID + a new path = a genuine Save-As in progress (relocate bank). This replaced two earlier iterations: GUID-only (mis-detected forks sharing a copied GUID) and pointer-primary (mis-detected reopen/new-project because it ignored the GUID on address recycling). Non-destructive preserved.

Milestone 5 — bank_panel (docked grid)

Goal: Docked LICE-drawn grid: thumbnails (from peaks), audition, multi-select, keyboard navigation. Reuses the docking setup from the retired mpe_view.cpp. CONTEXT.md §bank_panel. Verify (in DAW): Grid docks; thumbnails render from computed peaks; audition plays selected sample; multi-select + keyboard nav work.

  • Docked window + LICE grid render loop.
  • Thumbnail draw from peaks bins.
  • Audition (play selected sample) + stop.
  • Multi-select + keyboard navigation.

Notes/decisions:

  • Thumbnail cache: in-memory recompute keyed by (sampleId, drawWidth, bankGeneration); peak bins are NOT persisted alongside the index. Cache is discarded on bank change and rebuilt on next draw. (Closes the PLAN "thumbnail cache" open question.)
  • Audition: stock PlayPreview / StopPreview API, read-only — display + select + audition only, never inserts into the arrange. Single stop-funnel ensures a leak-free preview lifecycle. Flagged undocumented assumption: StopPreview detaches the source before returning; mitigated by the single-funnel design. Escalation path if a runtime pop appears: switch to StartPreviewFade + deferred free.

Milestone 6 — insert (placement)

Goal: "Insert selected sample at edit cursor" via InsertMedia. CONTEXT.md §insert, Build order 6. Verify (in DAW): Selected sample inserts at the edit cursor wrapped in Undo_BeginBlock2 / Undo_EndBlock2; conform-to-tempo is an explicit flag — no silent time-stretch when off.

  • InsertMedia(path, mode) at edit cursor (verify mode bits against SDK).
  • Conform-to-project-tempo vs literal as an explicit flag (never silent).
  • Undo-block wrapping.

Notes/decisions:

  • Placement target: inserts the focused bank sample onto the currently selected track(s) at the edit cursor (Daniel's directive — not a new track). Uses InsertMedia base mode 0; for multiple selected tracks the sample is placed on each at the same cursor position, then the original track selection and edit-cursor position are restored — non-destructive to editing state. The entire operation is one undo block.
  • No silent time-stretch: the &4 stretch-to-time-selection bit is never set; a pure insert_plan test asserts this across all mode combinations. Conform-to-project-tempo is an explicit separate action (&8), never on the default path.
  • Non-destructive to the bank: insert only adds arrange items — no bank/file/ext-state writes.

D1 — view_mode_model (pure)

Goal: REAPER-free mode registry + membership index + folder-tree-aware visibility derivation + parking/restore planner + JSON round-trip. The heart of the phase; mirror of bank_model. CONTEXT.md §Design View (Module architecture — pure). Verify: CTest green. N-mode model (not a boolean); Arrange + Design seeded. Restore-planner round-trip (snapshot → park → restore) returns every driven flag to its captured value. Parent-derivation correct against a supplied folder tree. JSON round-trip lossless across modes + membership + show-both + snapshots + active mode.

  • Mode registry: ordered (id, display name, ordinal); Arrange + Design seeded; add/query more modes (prove N-mode, not binary).
  • Membership index: GUID → { mode ids } + per-track show-both flag; add / remove / retag / query; untagged = Arrange.
  • Folder-tree-aware visibility derivation: given a supplied parent↔child tree + active mode, compute the visible set (active leaves, derived-visible parents, show-both leaves, master always in).
  • Parking/restore planner: emit exact (track, flag, value) op-lists for park and restore from active mode + snapshot record.
  • JSON round-trip: modes + membership + show-both + snapshots + active mode.
  • Tests: N-mode add/query; parent follows tagged leaf (multi-mode parent); restore-round-trip returns snapshot values (never hardcoded "on"); show-both leaf never parked; unknown/stale GUID tolerated; JSON lossless.

D2 — view shell (apply flags in the DAW)

Goal: Read the folder tree and drive REAPER flags per the planner. CONTEXT.md §Design View (view shell, REAPER API surface). Verify (in DAW): Toggling active mode hides + parks inactive leaves (B_SHOWINTCP/B_SHOWINMIXER/B_MAINSEND/I_FXEN + per-FX offline) and restores active ones from snapshot. Master untouched. B_MUTE/I_SOLO untouched. Untagged tracks untouched. Parents follow their tagged descendants.

  • Build parent↔child tree from I_FOLDERDEPTH; feed to view_mode_model.
  • Snapshot prior flag values (GetMediaTrackInfo_Value) before parking.
  • Apply park/restore ops (SetMediaTrackInfo_Value for the four flags; TrackFX_GetCount + per-FX TrackFX_SetOffline). Verify flag names/signatures.
  • GUID resolution: GetTrackGUID / guidToString / stringToGuid (never index).
  • Review gate: no path touches master visibility or B_MUTE/I_SOLO, or any untagged track's owned flags.

D3 — persist slice (view state ↔ project ext state)

Goal: Serialize the view section into the "reasampler" namespace alongside the bank; reapply the active mode on project open. CONTEXT.md §Design View (persist). Verify (in DAW): Membership + active mode + snapshots survive Save / Save As / close+reopen; on open, the active mode's visibility + processing is reapplied. Saved-while-parked project restores parked tracks from persisted snapshots (not to a guessed "on").

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

D4 — actions

Goal: Bindable action set for the mode workflow. CONTEXT.md §Design View (actions). Verify (in DAW): Each action registered (bindable in Actions list); toggle + mode-jumps MIDI-bindable; tag/untag acts on the current track selection.

  • Toggle active mode (cycle; extensible to cycle-all for >2 modes).
  • Activate mode: Arrange / Activate mode: Design (direct jumps).
  • Tag selected tracks → Design / → Arrange; Untag selected (= → Arrange).
  • Show-both for selected tracks (toggle).
  • Register each (command_id/gaccel/hookcommand); toggle + jumps MIDI-bindable.

Notes/decisions:

  • New src/actions.{h,cpp} — the Design View action family registered via the command_id/gaccel/hookcommand contract in main.cpp; MIDI-bindable.
  • New src/track_guid.{h,cpp} — shared MediaTrack* → canonical GUID-string formatter; used by both the view shell and the actions layer (single source of truth for membership keys).
  • Wiring: actions drive the D2 view shell and D3-persisted model; saved active mode is reapplied on project load via a load-signal seam in persist (loadFromProject raises it; main.cpp's timer drains it) — persist stays model-only.
  • A pure nextModeId free function added to view_mode_model (the N-mode cycle decision behind "toggle"), unit-tested in the existing view_mode_model_tests.

D5 — in-window toggle affordance (UI)

Goal: The segmented mode switch in the ReaSampler / bank_panel window header. CONTEXT.md §Design View (UI). Verify (in DAW): Segmented control shows current mode (lit segment), one click flips modes via the D4 toggle action, per-mode membership count visible, offlined-FX caveat surfaced as a tooltip.

  • Segmented mode switch [ Arrange | Design ] in the window header; active lit.
  • Wire the switch to the toggle/activate actions from D4.
  • Per-mode membership count display.
  • Offlined-FX re-init caveat as a tooltip on the switch.

Notes/decisions:

  • New PURE module src/mode_switch.{h,cpp} — REAPER-free layout math for the Design View mode switch: divides a header rectangle into N equal segments (one per registered mode) and hit-tests a point to a segment. Unit-tested via a new mode_switch_tests CTest target. Mirror of bank_grid.
  • src/bank_panel.cpp — segmented [ Arrange | Design ] control drawn in the panel header (one lit segment per registered mode, click activates that mode via view::applyMode), with the grid offset below the header.

D2-W1 — view_mode_model lane extension (pure)

Goal: Extend D1's pure planner to item level for the two-canvas sub-phase: lane↔mode mapping, a managed-vs-manual lane-ownership index, managed-only item-lane ops in the toggle planner, the "which lanes may this toggle touch" query, the auto-tag decision (manual-lane items exempt; pre-existing ⇒ Arrange), and JSON round-trip of the lane index. REAPER-free, unit-tested; mirror of D1. CONTEXT.md §Two-canvas sub-phase (Module architecture — pure). Verify: CTest green; D1 behavior and tests unchanged.

  • Lane↔mode mapping: which lane maps to which mode, which C_LANEPLAYS value per mode.
  • Lane-ownership index: per (track GUID, lane) managed-which-mode vs manual; managed-only item-lane op family alongside the existing track-flag op family.
  • "Which lanes may this toggle touch" query (managed only) — planner emits lane ops for managed lanes only, never for manual lanes.
  • Auto-tag decision (pure): new track/item GUIDs + active mode ⇒ membership writes; manual-lane items exempt; pre-existing ⇒ Arrange.
  • JSON round-trip of the lane-ownership index.
  • Tests: managed/manual partition; toggle-touches-managed-only; auto-tag exemption for manual-lane items; JSON lossless; D1 behavior/tests unchanged.

D2-W2 — shell: lane application + new-content detection

Goal: The view shell applies the planner's managed-lane ops in the DAW and the bank_panel timer detects new content and auto-tags it to the active mode. Resolves the two flagged implementation design points (I_FIXEDLANE reorder/renumber fragility; the auto-tag / manual-lane detection heuristic). See CONTEXT.md §Two-canvas sub-phase (Module architecture — shell; New-content detection). Verify (in DAW): Toggling a mode shows + plays only the active mode's managed lane, hides + silences the inactive-mode lane, and never touches a manual lane (its C_LANEPLAYS stays exactly as the user set it); new content created while a mode is active is tagged to that mode; pre-existing content stays Arrange (no mass-tag on the first poll after open). Depends on: D2-W1.

  • Apply managed-lane ops in the view shell (I_FREEMODE/I_FIXEDLANE/ C_LANEPLAYS/B_FIXEDLANE_HIDDEN via the item/track info setters; UpdateTimeline() after I_FREEMODE); managed lanes only, never manual. Verify every flag name/signature against the SDK header.
  • New-content detection on the bank_panel timer: diff the live track/item GUID set against the previous poll; tag any GUID new since the last poll to the then-active mode, with a first-poll-after-open guard (pre-existing ⇒ Arrange, no mass-tag) and the manual-lane exemption (items in a manual lane not tagged).
  • Resolve the manual-lane detection heuristic (which new items are exempt) and the I_FIXEDLANE lane-identity fragility (index survival across lane reorder/renumber/deletion) — the two open design points from CONTEXT.md.
  • D2-W1 review polish: document the one-managed-lane-per-mode-per-track exclusivity assumption in laneModeState (comment / debug-guard); clarify the serialize() one-line style note; optional round-trip tests for the last-writer-wins lane-replace contract.

Notes/decisions:

  • Two new pure modules added with unit tests: guid_diff (diffs live track/item GUID sets between polls) and lane_keys (manages lane identity via durable P_LANENAME rather than the renumber-prone I_FIXEDLANE ordinal, reconciled each apply — the resolution to the lane-identity fragility design point). CTest green.
  • Manual-lane protection: a single pure predicate isOnManualLane is the exclusive gate; manual lanes — including REAPER's default unnamed fixed lanes — are provably never driven or auto-tagged.
  • Track-level auto-tag and park behavior is live. Item-lane show/hide is correctly structured but is a provable no-op on real projects until D2-W3 mints the reasampler:-prefixed named lanes. End-to-end DAW verification of item-lane show/hide is sequenced after D2-W3 for this reason.
  • W1 review polish was folded in during this wave.

Milestone 7 — capture action family

Goal: Bindable capture actions for master / selected tracks / selected items / razor area, each with wet-dry + tail options. CONTEXT.md §actions, Build order 7. Verify (in DAW): Each action registered (bindable in Actions list), routes to the offline backend, and honors wet/dry + tail. Load-bearing principle: none auto-inserts into the arrange.

  • Source resolvers: master mix, selected tracks, selected items, razor area (GetSet_LoopTimeRange, P_RAZOREDITS, CountSelectedMediaItems, etc.).
  • Register each as a bindable action (command_id/gaccel/hookcommand).
  • Wet-dry + tail options per action.
  • Review gate: confirm no capture path touches the timeline.

Notes/decisions:

  • Four wet bindable capture actions — master mix, selected tracks, selected items, razor area — registered under the CEREBELLUM_REASAMPLER_CAPTURE_* command-id prefix. Each routes to OfflineRenderBackend (RENDER_* snapshot/restore, dither/normalize off, 32-bit float), produces a Sample, adds it to the bank, persists, and calls MarkProjectDirty. The M3 spike action was retired.
  • Wet-only decision (Daniel): REAPER offline render has no true pre-FX "dry" bit — the only wet/dry-adjacent lever (&8192 pre-fader stems) is post-FX. Approximate-dry action variants were removed rather than ship a "dry" that isn't. CaptureRequest.wetDry is retained as the seam for true dry (M10).
  • Pure render_settings module maps source mode → RENDER_SETTINGS bits and parses P_RAZOREDITS (union of track-audio areas), unit-tested. No capture path inserts into the arrange (load-bearing gate); non-destructive (selection/razor read-only).

Superseded / reworked (post-landing):

  • The four wet source-mode actions (master/tracks/items/razor) were replaced by three FX-scope actionscapture item, capture track, capture master — with range (razor-else-time-selection) inferred orthogonally. This fixed the defect where item captures were rendered through the parent FX chain.
  • FX-scope semantics (initial rework): item = item/take FX only; track = item FX + the selected track's own track FX; master = full chain. For item/track, the out-of-scope chain (ancestors + master, plus the item's own track for item scope) is neutralized during the render.
  • Master scope subsequently removed: capture is now two scopes — item and track only. CAPTURE_MASTER and CAPTURE_MASTER_REALTIME are retired (to capture the master, render a track instead). The master track is still neutralized as out-of-scope chain for both item and track captures; it is a bypass target, not a capture scope. The realtime backend taps the selected track (track scope only; item realtime deferred).
  • FxBypassGuard (RAII): snapshot → neutralize (FX bypassed via I_FXEN; gain zeroed via D_VOL; pan/width/pan-law/mode set to unity via D_PAN/D_WIDTH/D_PANLAW/I_PANMODE) → render → restore. Non-destructive. This guard is the reusable mechanism M8 (realtime backend) and M10 (null-test / true dry) build on.

Milestone 8 — RealtimeRecordBackend

Goal: Realtime record behind the same ICaptureBackend, producing identical bank entries. CONTEXT.md §capture (realtime), §Precision invariants. Verify (in DAW): Hidden temp track taps each selected track's own post-fader output via a CreateTrackSend; recorded file moves into the bank; non-destructive — temp track (and its sends) removed cleanly, every snapshotted track arm, time selection, and edit cursor restored unchanged on every terminal path.

  • Track-scope tap: a CreateTrackSend(source, temp) from each selected track into a hidden temp track (B_MAINSEND=0, hidden from TCP/mixer). The temp records its own post-fader output — capturing each source track's output after its own FX and fader, before the parent/folder/master sums it — chain-independent by construction. No FxBypassGuard needed or used. Multiple selected tracks sum in the temp track (matching offline track scope). Item realtime deferred (UnsupportedMode). No track selected → refused.
  • Timer-driven async state machine (begin/tick/abort driven by OnTimer, non-blocking — REAPER's UI stays responsive across the record). begin() validates, snapshots all state, creates the temp track, routes the tap, arms, calls CSurf_OnRecord, and returns immediately. tick() (called from OnTimer) reads the transport via GetPlayStateEx/GetPlayPositionEx scoped to the record's own ReaProject* (project-switch safe), advances the pure advanceRecordPhase state machine, and on a terminal verdict stops + finalizes/restores. abort() is the force-terminate path for shutdown and project switch.
  • RealtimeCaptureState snapshot + idempotent restore: snapshots cursor, time selection, and every other track's I_RECARM; restore() is latched (restored_ flag) and safe to call from whichever terminal path fires first. Terminal paths: normal completion, manual stop, error, second-capture reject, project switch (project-scoped OnStopButtonEx(proj_), never the global CSurf_OnStop), project close (guarded by ValidatePtr2(nullptr, proj_, "ReaProject*") — a closed project calls dropWithoutRestore() rather than touching freed pointers), and extension unload.
  • Finalizing flush-wait before file move: after the transport stops, tick() waits for the recorded file size to be positive and stable across a tick before calling finalizeRecording (file is no longer being written by REAPER's audio thread). A wall-clock ceiling (steady-clock, independent of the play cursor) bounds both the total record duration and the flush wait separately.
  • Move recorded source into bank: recordedFilePath discovers the take's source file from the temp track's first media item; finalizeRecording moves it into the bank folder (cross-volume fallback: copy+remove); populates a Sample via sampleFromRecordedCapture; clean teardown via restore() deletes the temp track (which REAPER uses to automatically remove every send routed into it).
  • Dialog-free; realtime is inherently non-deterministic (documented, not asserted bit-identical); saved-project gate (refuses + prompts Save-As if unsaved, matching offline). Bindable cancel action registered. Master scope removed entirely — to capture the master, render a track.

Notes/decisions:

  • Track scope only this increment. Item realtime is deferred: item scope needs per-item take isolation on top of the track-output tap — a separate increment.
  • TAP vs. FxBypassGuard. The CreateTrackSend defaults to post-fader (I_SENDMODE=0) with full-stereo (I_SRCCHAN default): post-fader taps the source track after its own FX and fader/pan, before the parent sums it. The parent chain downstream of that branch is not in the tapped path at all — so there is nothing to neutralize and FxBypassGuard (which mutates the live chain, altering the user's monitoring) is deliberately not used. This also fixed the earlier silent-file bug from the spike, which sent FROM the master INTO a temp track (a feedback loop REAPER refuses, recording silence). A regular track→track send has no feedback.
  • Project-close guard. abort() gates every REAPER call on ValidatePtr2(nullptr, proj_, "ReaProject*"). A closed project already reclaimed its temp track, arms, and transport — dropWithoutRestore() latches restored_ and clears temp_ / armSnaps_ without touching any REAPER pointer.
  • No undo block. The transient mutations (temp track, sends, arm, transport) are fully reversed by restore(); surfacing them as an undo point would pollute the user's history with an internal scaffold they cannot meaningfully undo.

T1 — offline tail: auto (default) + manual override

Goal: Preserve decay tails on offline captures. Auto: render an 8 s-capped tail, then auto-trim trailing silence to -72 dB via a surgical RENDER_NORMALIZE (only the trim-end bit, 32768) + a derived RENDER_TRIMEND amplitude ratio. Manual: a fixed tail length clamped to the 8 s cap, no trim. None (default): exact bounds, byte-identical to the pre-tail capture. See docs/product/capture-tail.md §The offline path. Verify (in DAW): A range ending mid-reverb + Auto tail ends at the -72 dB decay point (not a hard 8 s, not the range end); a non-decaying signal caps at range + 8 s; two identical Auto requests are byte-identical (deterministic trim); a TailMode::None capture is byte-identical to the pre-tail exact-bounds capture; Manual(N ms) yields range + N ms untrimmed, with N clamped to 8000; ScopedRenderSettings restores RENDER_NORMALIZE and every touched setting on every path.

  • Pure layer (render_settings.{h,cpp}): named constants kAutoTrimThresholdDb (-72) + derived RENDER_TRIMEND amplitude ratio via autoTrimEndRatio() (≈ 0.00025119 for -72 dB, computed as 10^(dB/20)std::pow is not constexpr before C++26 so this is a function, not a constant), kMaxTailSeconds/kMaxTailMs (8 s); TailMode { None, Auto, Manual } enum; TailRenderSettings struct (tailFlag/tailMs/normalize/trimEnd); tailRenderSettingsFor(mode, manualTailMs) mapping (None = kTailFlagNone + kNormalizeDisableAll; Auto = kTailFlagCustomBounds
    • kMaxTailMs + kNormalizeTrimEnd (32768) + autoTrimEndRatio(); Manual = kTailFlagCustomBounds + clamped ms + kNormalizeDisableAll); unit-tested.
  • Wire the mapping into OfflineRenderBackend (capture.cpp): drives tail + surgical-normalize (Auto) / disable-all (Manual/None) via GetSetProjectInfo; ScopedRenderSettings snapshots and restores RENDER_TRIMEND alongside the existing RENDER_* set. RENDER_TAILFLAG = kTailFlagCustomBounds (1) for Auto and Manual — custom bounds is the always-applicable tail bit for offline captures.
  • CaptureRequest three-state tail contract (None/Auto/Manual(ms)); default None (exact bounds, null-test-safe). The earlier renderTail bool/tailMs pair was superseded.
  • Exposure: a docked-panel footer toggle (label "Tail: Off" / "Tail: Auto" / "Tail: Manual", cycles on click via cycleTailMode) in bank_panel.cpp, backed by the pure tail_control module (TailSetting, cycleTailMode, clampManualMs, tailToggleLabel — unit-tested). Default TailMode::None. CAPTURE_ITEM and CAPTURE_TRACK read the panel setting at fire time — no per-action tail variants shipped (the "…with tail" variants were dropped in favour of the toggle; null-test/verify captures use None explicitly).
  • DAW-confirm: RENDER_TRIMEND amplitude curve (0.00025119 ≈ -72 dB); trim-end-only normalize (32768) does not engage fades/normalize/pad; trim never eats pre-ENDPOS body. (See spec §Open questions / DAW-confirm.)

Notes/decisions:

  • Surgical normalize. kNormalizeTrimEnd = 32768 sets only the trim-ending-silence bit; every other postprocessing bit is clear. A fixed-threshold trailing-silence trim scales and fades nothing, so two identical Auto requests trim at the identical sample → bit-identical repeats hold (spec §surgical normalize).
  • kNormalizeDisableAll = (4 << 16) = 262144. Used for None and Manual — the same disable-all value the pre-tail exact-bounds capture used.
  • tail_control pure module (src/tail_control.{h,cpp}): REAPER-free logic for the panel toggle. kDefaultManualTailMs = 2000.0 (2 s). Fine-adjust UI (scroll-wheel in 250 ms steps) and per-project persistence landed as T1-followons (see below).
  • Follow-ons resolved: Manual fine-adjust UI and per-project persistence of the toggle landed as T1-followons. T2 realtime tail landed separately.

T2 — realtime tail (follow-on to T1)

Goal: The parallel tail path for the M8 realtime backend, which does not drive RENDER_*: record an 8 s-capped tail window past the range end, then trim in a PCM decay-scan to the -72 dB point (Manual = record fixed tail, skip the scan). See docs/product/capture-tail.md §The realtime path. Verify (in DAW): A realtime Auto capture of a decaying source records ≥ the range then trims at the -72 dB decay point (± inherent realtime tolerance); realtime tail is not asserted bit-identical (documented non-determinism). Depends on: T1, M8.

  • Record [start, end + clamp(tail, 8 s)] (extend the record time selection in capture_realtime.cpp); Manual skips the scan, Auto proceeds to it.
  • Pure decay-scan helper alongside peaks: lastFrameAboveThreshold(interleaved, channels, frames, linearThreshold) -> frameIndex (backward scan, per-frame max-abs across channels, no fold); unit-tested with a synthetic decaying ramp. (Spec §realtime path option (a) — recommended over bending computeEnvelope.)
  • Realtime shell: read the recorded wav PCM into a float buffer, find the trim frame, rewrite the file truncated (new I/O the backend does not do today).

Notes/decisions:

  • New pure module wav_trim (src/wav_trim.{h,cpp}): 32-bit-float WAV parse + header-aware truncate plan (RIFF/data size rewrite). Rejects WAVE_FORMAT_EXTENSIBLE with non-float SubFormat GUID. Depends on peaks for the AudioSample float alias. Unit-tested via a new wav_trim_tests CTest target.
  • peaks gained lastFrameAboveThreshold (backward PCM scan, per-frame max-abs across channels, no fold) for the Auto decay scan.
  • Auto/Manual/Off semantics. Auto: records [start, end + 8 s cap], scans backward for the last frame above -72 dBFS, truncates the WAV header-aware at that frame. Manual: records [start, end + fixed tail], skips the scan. Off: byte-identical to the pre-tail exact-bounds capture.
  • Realtime tail is non-deterministic by design (inherent to the realtime backend). Bit-identical repeats are not asserted for the realtime path; this is documented, not a defect.

T1-followons — Manual fine-adjust UI + per-project tail persistence

Goal: Close the two follow-ons deferred at T1 landing: (1) scroll-wheel fine-adjust of the Manual tail length in the panel footer; (2) the tail setting (mode + Manual length) persists per-project inside the .rpp rather than resetting on extension unload. Verify (in DAW): Scroll-wheel over the footer adjusts Manual length in 250 ms steps, clamped 08 s; the label reads "Tail: Manual X.Xs" (one decimal) in Manual mode; footer click still cycles Off → Auto → Manual. The tail setting survives Save / close+reopen; projects with no stored key fall back to Off / 2 s. Depends on: T1.

  • adjustManualMs(current, notches, stepMs) pure helper in tail_control (per-notch ±kManualStepMs = 250 ms, clamped [0, kMaxTailMs]); unit-tested.
  • tailToggleLabel updated: Manual mode appends the clamped length in seconds to one decimal, e.g. "Tail: Manual 2.0s"; unit-tested at boundary lengths.
  • Panel footer scroll-wheel handler calls adjustManualMs and repaints; click handler unchanged (still cycles mode via cycleTailMode).
  • serializeTailSetting / deserializeTailSetting pure round-trip (mode + manualMs) added to tail_control; unit-tested including std::nullopt on malformed input.
  • TailSetting tail_ promoted into ReaSamplerSession (peer to bank_ and view_); persist serializes it under the forever-stable key "tail_setting" (namespace "reasampler") on save and reloads it on project open. Absent key → default Off / 2 s (graceful for older/unsaved projects).
  • Changing the toggle marks the project dirty and commits the value to ext state; bankPanelTailSetting() reads through the session (not a panel-local copy).

Notes/decisions:

  • kManualStepMs = 250.0 — Daniel-set coarse-but-precise step; one wheel notch = ± 250 ms.
  • Label format: "Tail: Manual 2.0s" (one decimal, s suffix) — format pinned by unit tests.
  • Default fallback on absent/malformed key: TailSetting { TailMode::None, kDefaultManualTailMs } (Off mode, 2 s stored length) — graceful for projects saved before this feature shipped.
  • kProjExtTailKey = "tail_setting" is forever-stable (changing it would orphan saved choices, falling back to the default — graceful but lossy).

D2-W3-A — lane minting + item→lane assignment + persist round-trip

Goal: The functional core that makes item-lanes appear: a pure planLaneMinting decision (which tracks hold >1 mode's content, which managed lane each item lands on) plus the shell apply path in view.cpp — enables fixed-lane mode, mints one managed reasampler:<mode>-named lane per involved mode, assigns each item (including pre-existing) to its mode's lane, and drives per-lane play state, all under one undo block, triggered off the auto-tag detection tick. Reconciles the lane-ownership index from durable lane names on project load before active-mode visibility is reapplied. The lane-ownership index persists inside the "reasampler" view_state blob (rides in ViewModeModel::serialize() / deserialize()). Verify: CTest green (14/14). Pure decision unit-tested in view_mode_model_tests. DAW verification pending (Daniel testing on dev): two behaviors are REAPER-runtime-only — whether lane names stick when written on the same tick the track flips to fixed-lane mode, and whether the leftover empty default lane 0 is silent. Depends on: D2-W2.

  • Pure planLaneMinting decision (view_mode_model.{h,cpp}): for each reported track, collect the distinct modes of managed-eligible items; if < 2 modes, no split (D1 whole-track parking still separates stances); if ≥ 2 modes, emit one TrackSplit, one LaneMint per involved mode (durable key = laneNameForMode(mode), owned by that mode), and one LaneAssign per managed-eligible item — including pre-existing items, so a track that just gained a second mode retroactively lanes all its content. Manual-lane items (onManualLane = true) are exempt at the source: never counted, never reassigned, never minted-over.
  • Shell apply path applyMintPlan in view.cpp: enables I_FREEMODE = fixed lanes, grows I_NUMFIXEDLANES (never shrinks — user's manual lanes are never deleted), stamps each managed lane's durable name via P_LANENAME, records ownership in the model (lanes().setManaged), assigns each item to its mode's lane via I_FIXEDLANE resolved from the durable key. Returns changed so the Undo block is only kept when state actually changed (idempotent re-runs produce no undo point).
  • Per-lane play state driven immediately after minting: planToggle lane ops applied via applyLaneOps so the freshly-minted lanes take the correct C_LANEPLAYS state for the active mode without a full applyMode re-run (which would re-park/restore whole tracks — not correct for a minting tick).
  • mintManagedLanes entry point in view.cpp: reads live track/item picture via readLaneTracks, calls planLaneMinting, wraps the apply in one Undo block labelled "ReaSampler: separate cross-mode content into lanes", calls UpdateTimeline() + UpdateArrange() after a fixed-lane mode change.
  • reconcileManagedLanes in view.cpp: on project load, reads every fixed-lane track's P_LANENAME values; for each name carrying the managed prefix, records the lane as managed-for-its-mode in the ownership index — pure read of REAPER state, no lane created or renamed. Called from main.cpp's load path before applyMode.
  • Lane-ownership index persists via ViewModeModel::serialize() / deserialize() — the LaneOwnershipIndex is a member of ViewModeModel and round-trips inside the "reasampler" view_state key alongside modes, membership, snapshots, and active mode. No new persistence key required.
  • Detection tick integration: mintManagedLanes is called from the bank_panel timer after the auto-tag pass, so a newly-tagged multi-mode track is split into lanes on the same tick the content is detected.

Notes/decisions:

  • Single-mode-track rule: a track carrying content of only ONE mode is not split — D1's whole-track parking continues to separate its stance from the other mode without lane overhead. The lane-split only engages when a track genuinely holds ≥ 2 modes' content.
  • Manual-lane invariant upheld at the source: planLaneMinting never receives manual-lane items as split candidates. The shell's readLaneTracks marks items on manual lanes onManualLane = true; the pure decision skips them entirely. Managed lanes are always appended (tail ordinals), never overwriting a user's existing lanes.
  • Idempotency: re-reporting an already-split track produces the same plan; the shell's ensure/assign writes are no-ops when state already matches. The Undo block is closed with no label (discarded by REAPER) when the plan is non-empty but every write was already satisfied, so no phantom undo points accumulate.
  • Review passed with no Critical or Major findings.