a786915451
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.
657 lines
43 KiB
Markdown
657 lines
43 KiB
Markdown
# 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 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.
|
||
|
||
- [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.
|