Archive Milestone 8 and Milestone T1 in docs

Move completed M8 (async realtime backend — landed as selected-track post-fader
tap, not master) and T1 (offline capture tail + panel toggle) from PLAN.md to
COMPLETED.md; resolved open questions removed. T2 realtime tail remains.
This commit is contained in:
2026-07-23 17:52:03 -04:00
parent e3d126c177
commit 81fa37fe94
2 changed files with 126 additions and 53 deletions
+126
View File
@@ -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.