diff --git a/COMPLETED.md b/COMPLETED.md index d92030f..c4a3b22 100644 --- a/COMPLETED.md +++ b/COMPLETED.md @@ -307,3 +307,129 @@ auto-inserts into the arrange. - **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 (± + click zones / scroll) is a noted follow-on; this pass ships a fixed default. +- **Follow-ons noted, not done:** Manual fine-adjust UI; per-project persistence of + the toggle (currently extension-session lifetime, resets to None on unload); T2 + realtime tail. diff --git a/PLAN.md b/PLAN.md index fb4682d..f637adc 100644 --- a/PLAN.md +++ b/PLAN.md @@ -14,21 +14,6 @@ it here and appends it to `COMPLETED.md`. --- -## 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 resamples wet output; recorded file moves -into the bank; **non-destructive** — temp track removed cleanly, source routing -and user monitoring restored unchanged. - -**Note (from M3):** The realtime backend captures during playback and does NOT invoke the offline-render path, so it is inherently dialog-free (no render-progress window) — a secondary benefit beyond hardware/performed-FX capture. - -- [ ] Hidden-track resample recipe (`I_RECMODE`/`I_RECINPUT`/`I_RECARM`, - `CSurf_OnRecord`/`CSurf_OnStop`); verify record-mode values against SDK. -- [ ] Resolve wet-master routing that does not alter user monitoring (open - question). -- [ ] Move recorded source into bank; populate identical `Sample`; clean teardown. - ## Milestone 9 — slots (MPC-style) **Goal:** "Capture to slot N" / "insert slot N", MIDI-bindable. CONTEXT.md Build order 9. @@ -72,8 +57,6 @@ invariants; drag-out places a valid file in the OS target. Carried from CONTEXT.md §Open questions — keep visible until each is closed by a landed milestone. -- **Realtime wet-master routing** that captures master output without altering the - user's monitoring. (blocks M8) - **`parseInt` narrowing hardening:** `src/bank_model.cpp` `parseInt` casts `int64_t → int` via `static_cast` without a range check; integers that fit in int64 but exceed `INT_MAX` are implementation-defined. Hardening candidate @@ -94,34 +77,6 @@ landed milestone. > threshold **-72 dB**, max-tail cap **8 s**. When a point lands, doc-keeper moves > it to `COMPLETED.md`. -## T1 — offline tail: auto (default) + manual override -**Goal:** Preserve decay tails on offline captures. **Auto** (default): render an -8 s-capped tail, then auto-trim trailing silence to -72 dB via a **surgical** -`RENDER_NORMALIZE` (only the trim-end bit set) + `RENDER_TRIMEND`. **Manual**: a -fixed tail length (clamped to the 8 s cap), no trim, keeping today's disable-all -normalize. Tail is opt-in; **None** stays byte-identical to today. 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` ratio (≈0.00025119), `kMaxTailSeconds`/`kMaxTailMs` - (8 s); a `TailMode { None, Auto, Manual }` → (`RENDER_TAILFLAG`/`RENDER_TAILMS`/ - `RENDER_NORMALIZE`/`RENDER_TRIMEND`) mapping + the manual-tail clamp; unit-tested. -- [ ] Wire the mapping into `OfflineRenderBackend` (`capture.cpp`): drive the tail + - surgical-normalize (Auto) / disable-all (Manual/None) values; snapshot/restore - `RENDER_TRIMEND` alongside the existing `RENDER_*` set. `RENDER_TAILFLAG = 1` - unconditionally (custom bounds — not per range type). -- [ ] Replace/extend `CaptureRequest.renderTail`(bool)/`tailMs` with the three-state - tail contract (None/Auto/Manual(ms)); default None (exact bounds, null-test-safe). -- [ ] 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.) - ## 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 @@ -141,14 +96,6 @@ then trims at the -72 dB decay point (± inherent realtime tolerance); realtime - [ ] 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). -## Milestone T open questions -- **Auto as the shipped-action default?** Whether `CAPTURE_ITEM`/`CAPTURE_TRACK` - (currently all TailMode::None) flip to Auto, gain a "…with tail" variant, or take - a modifier. Product call for Daniel; **leaning** paired variant / toggle over - silently changing the exact-bounds default. Not blocking T1 (the request-level - three-state contract is independent). (touches `render_settings.cpp` action table + - `actions`.) - --- # Phase D2 — Two-canvas (item-level mode projection; additive to D1)