Files
reasampler/COMPLETED.md
T
daniel a786915451 docs: record D2-W3-B landing; mark Phase D2 complete; preserve deferred panel indicator
Move D2-W3-B (item-level mode actions + W3-A polish) from PLAN.md to COMPLETED.md.
Phase D2 two-canvas sub-phase functionally complete; panel UI indicator explicitly
deferred, not dropped.
2026-07-25 14:12:12 -04:00

657 lines
43 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.
- [x] Delete `src/mpe_model.{h,cpp}` and `src/mpe_view.{h,cpp}`; remove
`tests/test_mpe_model.cpp`.
- [x] 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.
- [x] Replace the pure `mpe_model` static lib + `mpe_model_tests` executable with
`bank_model` (pure static lib) + `bank_model_tests`; keep the CTest wiring.
- [x] 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.
- [x] 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).
- [x] 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).
- [x] 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.
- [x] `BankIndex`: ordered collection keyed by id; add / remove / query.
- [x] Hash lookup for dedup-by-content-hash.
- [x] Tier model (scratch | archive) + tier-move + tier filtering; scratch marked
auto-prunable.
- [x] JSON serialize/deserialize to/from `std::string`.
- [x] 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).
- [x] Min/max bin computation from interleaved PCM given a target bin count.
- [x] Multi-channel handling (per-channel envelope; no silent fold).
- [x] 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).
- [x] `ICaptureBackend` interface + `CaptureRequest` (source mode, time range,
wet/dry, tail, SR/bit-depth/channels, output path). CONTEXT.md §capture.
- [x] `OfflineRenderBackend`: drive `GetSetProjectInfo` render settings +
`GetSetProjectInfo_String` file/pattern/format; **verify every flag against
`vendor/reaper-sdk/sdk/reaper_plugin_functions.h`.**
- [x] Resolve the no-dialog render command/flag on the current REAPER build
(open question) and confirm it runs headless.
- [x] Populate a `Sample` from the finished file; hand to `bank_model`; console-log.
- [x] 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).
- [x] `SetProjExtState` / `GetProjExtState` under namespace `"reasampler"`.
- [x] Bank-folder resolution from the current project path
(`EnumProjects` / `GetProjectPathEx`); store under a project-relative subfolder.
- [x] 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.
- [x] Docked window + LICE grid render loop.
- [x] Thumbnail draw from `peaks` bins.
- [x] Audition (play selected sample) + stop.
- [x] 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.
- [x] `InsertMedia(path, mode)` at edit cursor (verify mode bits against SDK).
- [x] Conform-to-project-tempo vs literal as an explicit flag (never silent).
- [x] 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.
- [x] Mode registry: ordered (id, display name, ordinal); Arrange + Design seeded;
add/query more modes (prove N-mode, not binary).
- [x] Membership index: `GUID → { mode ids }` + per-track show-both flag;
add / remove / retag / query; untagged = Arrange.
- [x] 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).
- [x] Parking/restore planner: emit exact (track, flag, value) op-lists for park and
restore from active mode + snapshot record.
- [x] JSON round-trip: modes + membership + show-both + snapshots + active mode.
- [x] 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.
- [x] Build parent↔child tree from `I_FOLDERDEPTH`; feed to `view_mode_model`.
- [x] Snapshot prior flag values (`GetMediaTrackInfo_Value`) before parking.
- [x] Apply park/restore ops (`SetMediaTrackInfo_Value` for the four flags;
`TrackFX_GetCount` + per-FX `TrackFX_SetOffline`). Verify flag names/signatures.
- [x] GUID resolution: `GetTrackGUID` / `guidToString` / `stringToGuid` (never index).
- [x] 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").
- [x] Serialize/deserialize the view section under `"reasampler"` (shared blob,
distinct section from the bank index).
- [x] Reapply active mode on project open (rebuild tree, run the planner).
- [x] 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.
- [x] Toggle active mode (cycle; extensible to cycle-all for >2 modes).
- [x] Activate mode: Arrange / Activate mode: Design (direct jumps).
- [x] Tag selected tracks → Design / → Arrange; Untag selected (= → Arrange).
- [x] Show-both for selected tracks (toggle).
- [x] 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.
- [x] Segmented mode switch `[ Arrange | Design ]` in the window header; active lit.
- [x] Wire the switch to the toggle/activate actions from D4.
- [x] Per-mode membership count display.
- [x] 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.
- [x] Lane↔mode mapping: which lane maps to which mode, which `C_LANEPLAYS` value
per mode.
- [x] 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.
- [x] "Which lanes may this toggle touch" query (managed only) — planner emits lane
ops for managed lanes only, never for manual lanes.
- [x] Auto-tag decision (pure): new track/item GUIDs + active mode ⇒ membership
writes; manual-lane items exempt; pre-existing ⇒ Arrange.
- [x] JSON round-trip of the lane-ownership index.
- [x] 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.
- [x] 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.
- [x] 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).
- [x] 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.
- [x] 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.
- [x] Source resolvers: master mix, selected tracks, selected items, razor area
(`GetSet_LoopTimeRange`, `P_RAZOREDITS`, `CountSelectedMediaItems`, etc.).
- [x] Register each as a bindable action (`command_id`/`gaccel`/`hookcommand`).
- [x] Wet-dry + tail options per action.
- [x] 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 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_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.
- [x] 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.
- [x] 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.
- [x] `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.
- [x] `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.
- [x] 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).
- [x] 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.
- [x] 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.
- [x] 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.
- [x] `CaptureRequest` three-state tail contract (None/Auto/Manual(ms)); default
None (exact bounds, null-test-safe). The earlier `renderTail` bool/`tailMs` pair
was superseded.
- [x] 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).
- [x] 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.
- [x] Record `[start, end + clamp(tail, 8 s)]` (extend the record time selection in
`capture_realtime.cpp`); Manual skips the scan, Auto proceeds to it.
- [x] 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`.)
- [x] 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.
- [x] `adjustManualMs(current, notches, stepMs)` pure helper in `tail_control` (per-notch
±`kManualStepMs` = 250 ms, clamped [0, `kMaxTailMs`]); unit-tested.
- [x] `tailToggleLabel` updated: Manual mode appends the clamped length in seconds to one
decimal, e.g. `"Tail: Manual 2.0s"`; unit-tested at boundary lengths.
- [x] Panel footer scroll-wheel handler calls `adjustManualMs` and repaints; click handler
unchanged (still cycles mode via `cycleTailMode`).
- [x] `serializeTailSetting` / `deserializeTailSetting` pure round-trip (mode + manualMs)
added to `tail_control`; unit-tested including `std::nullopt` on malformed input.
- [x] `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).
- [x] 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.
- [x] 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.
- [x] 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).
- [x] 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).
- [x] `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.
- [x] `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`.
- [x] 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.
- [x] Detection tick integration: `mintManagedLanes` is called from the `bank_panel`
timer after the auto-tag pass, so a newly-tagged multi-mode track is split into lanes
on the same tick the content is detected.
**Notes/decisions:**
- **Single-mode-track rule:** a track carrying content of only ONE mode is not split —
D1's whole-track parking continues to separate its stance from the other mode without
lane overhead. The lane-split only engages when a track genuinely holds ≥ 2 modes'
content.
- **Manual-lane invariant upheld at the source:** `planLaneMinting` never receives
manual-lane items as split candidates. The shell's `readLaneTracks` marks items on
manual lanes `onManualLane = true`; the pure decision skips them entirely. Managed
lanes are always appended (tail ordinals), never overwriting a user's existing lanes.
- **Idempotency:** re-reporting an already-split track produces the same plan; the
shell's ensure/assign writes are no-ops when state already matches. The Undo block is
closed with no label (discarded by REAPER) when the plan is non-empty but every write
was already satisfied, so no phantom undo points accumulate.
- **Review passed** with no Critical or Major findings.
---
## D2-W3-B — item-level mode actions + W3-A polish
**Goal:** Item-level lane/mode-management actions mirroring the track-level Design
View tag family (bindable in the Actions list), plus the three code-review polish
items carried from D2-W3-A. The persist slice and lane-ownership index round-trip
were completed in D2-W3-A; this wave closes the remaining action surface and
cleans up the implementation.
See CONTEXT.md §Two-canvas sub-phase (Module architecture — persistence).
**Verify (in DAW):** Item mode actions registered and MIDI-bindable in the Actions
list; re-drive mint/apply so each item lands on its mode's managed lane; manual-lane
items exempt; one undo block per action. ctest 14/14 green.
**Depends on:** D2-W3-A.
- [x] "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.
- [x] "Move selected items → Arrange" action: retags selected items' membership to
Arrange mode, re-drives mint/apply; manual-lane items exempt; one undo block.
- [x] "Untag selected items" action: removes selected items' membership, re-drives
mint/apply; manual-lane items exempt; one undo block.
- [x] All three registered (`command_id`/`gaccel`/`hookcommand`); MIDI-bindable.
- [x] W3-A polish — simplified `applyMintPlan`'s redundant `I_NUMFIXEDLANES` re-read:
single grow-and-track pass removes the second `GetMediaTrackInfo_Value` call inside
the mint loop.
- [x] W3-A polish — extracted shared item-read seam (`src/item_read.{h,cpp}`):
removes duplicated `itemGuid`/`itemLaneName` read logic from `view.cpp` and
`bank_panel.cpp`.
- [x] 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.