Files
reasampler/docs/product/capture-tail.md
T
daniel fa69b6c547 docs: record the millisecond floor as located and closed, and split what stayed open
The retired custom-bounds premise is corrected wherever it was encoded: the tail
bit is the time selection's, not custom bounds'.
2026-08-02 17:22:20 -04:00

413 lines
23 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.
# Capture tail — spec
Authoritative spec for the **capture-tail** feature: preserving reverb/release
tails that decay past the end of a capture range. The tickable milestone's landed
history is in `docs/ARCHIVE.md` (Milestone T); this doc holds the full technical
detail **and** the product framing.
> **Why this doc carries the technical spec (not the architecture docs).** Every
> other pillar (capture M0M11, Design View, Multi-bank) keeps its authoritative
> technical spec as a per-directory `src/**/CLAUDE.md` section and its *why* in a
> `docs/product/` note. Capture-tail is a rider on the already-shipped
> offline-render path (M3/M7), not a standalone pillar, and it was specced without
> reopening the architecture spec. So the authoritative detail lands **here**,
> house-styled to match those specs; the landed invariant deltas are folded into
> root `CLAUDE.md` §Precision invariants and `src/core/capture/CLAUDE.md` /
> `src/shell/capture/CLAUDE.md` as landed history. Same standing discipline
> applies: **verify every REAPER API name/flag against
> `vendor/reaper-sdk/sdk/reaper_plugin_functions.h` before use** — the flag values
> below are transcribed from that header (line numbers cited) and are not guesses.
Status: specced (2026-07-23), aligned with Daniel. Parameters set by Daniel:
**auto-trim silence threshold = -72 dB**, **max-tail cap = 8 s**. Open items and
DAW-confirm targets are at the bottom.
---
## Goal
A reverb or release tail that rings out past a capture range's end should be able
to land in the capture — cleanly, without the user hand-measuring where the decay
falls silent, and without ever bloating an exact-bounds capture with silence it did
not ask for.
Two modes:
- **Automatic (default).** Render a *generous* tail (the 8 s cap), then auto-trim
the trailing silence to -72 dB, so the capture ends where the tail naturally
decays. No user input.
- **Manual override.** A fixed tail length, no trim — explicit control for the user
who wants exactly N seconds of tail (e.g. a rhythmic reverb throw held to a bar).
The tail is **opt-in** in both modes. A capture with no tail requested is exact
bounds, byte-for-byte, unchanged from today — this is load-bearing for the null
test (see Invariant interactions).
---
## The offline path (the primary path)
The offline `OfflineRenderBackend` (`src/capture.cpp`) already drives every
`RENDER_*` setting through `GetSetProjectInfo` behind a `ScopedRenderSettings`
snapshot/restore, forces dither and all normalize-postprocessing off, and renders
32-bit float. The tail wires into that existing path — no new render trigger, no
new backend.
### Bounds are always the time selection — so the tail bit is always `&4`
The backend renders with `RENDER_BOUNDSFLAG = 2` (time selection) for **every**
scope and every range type: it writes the request's exact seconds into the
project's own time selection via `GetSet_LoopTimeRange` (`capture.cpp` ~L470477;
`RENDER_STARTPOS`/`RENDER_ENDPOS` are also written, as a defensive no-op for a
mode-0-only field, but the window itself travels in the time selection). It does
**not** use the custom-time-bounds mode (`RENDER_BOUNDSFLAG = 0`) — that mode was
tried and retired: DAW observation showed REAPER resolving a custom-bounds window
on a whole-millisecond grid AT RENDER TIME, flooring the end and rendering exactly
the floored frame count, which silently broke the exact-bounds precision
invariant. The time-selection mode does not floor the window. (The one narrative
home for that finding is `render_settings.h`'s `kRenderBoundsTimeSelection`; this
doc points there rather than retelling it.)
`RENDER_TAILFLAG` is a bitmask keyed to the **bounds mode**, not the capture range
type (header line 3047):
```
RENDER_TAILFLAG : &1=custom time bounds, &2=entire project, &4=time selection,
&8=all project markers/regions, &16=selected media items,
&32=selected project markers/regions
```
Because we always render in time-selection mode, **the only tail bit that ever
applies is `&4`**. There is no per-range-type tail-flag decision to make — a razor
capture, a time-selection capture, and an item capture are all time-selection-bounds
renders under the hood, so all three take `RENDER_TAILFLAG = 4`.
> **Correction to the framing brief.** The brief asked us to pick a
> `RENDER_TAILFLAG` bit *per capture range type* (time selection vs. razor vs. item)
> and flagged `&32` as "markers/regions." The header (line 3047) says `&32` =
> *selected project regions* and `&8` = *all markers/regions* — but neither matters:
> our renders are all `RENDER_BOUNDSFLAG = 2`, so the tail bit is `&4` unconditionally.
> The existing `kTailFlagTimeSelection = 4` constant in
> `src/core/capture/render_settings.h` (the bounds mode's own bit, per bounds mode —
> header line 3047) is already correct — it was right from the start; the wording
> above it (which had assumed a custom-bounds render) was what was wrong.
### Mode 1 — Automatic (default): generous tail + auto-trim to -72 dB
Set, in addition to the exact `STARTPOS`/`ENDPOS` already driven:
| Setting | Value | Meaning / header ref |
|---|---|---|
| `RENDER_TAILFLAG` | `4` | apply tail for time selection (line 3047, `&4`) |
| `RENDER_TAILMS` | `8000` | the 8 s cap, in ms (line 3048) |
| `RENDER_NORMALIZE` | `32768` | **only** the trim-ending-silence bit (line 3051, `&32768`) |
| `RENDER_TRIMEND` | `≈ 0.000251` | -72 dB threshold (line 3062; scaling below) |
The render produces up to 8 s of tail past `ENDPOS`, then REAPER trims the trailing
silence back to the -72 dB threshold, so the file ends where the decay crosses -72
dB. Non-decaying signal (a sustained pad, a loop) never falls below -72 dB, so the
8 s cap is what stops it — that is the cap's whole job (runaway guard).
**The surgical `RENDER_NORMALIZE` — this is the subtle part.** Today the backend
forces `RENDER_NORMALIZE = 4<<16 = 262144` (`kNormalizeDisableAll`, `capture.cpp`
~L92, L403): *disable all render postprocessing*. That single "disable all" bit
masks out normalization, brickwall, fades, pad, and trim in one move.
Auto-trim needs the trim-ending-silence bit **on** — which means we can no longer
use the disable-all bit (they are semantically opposed; disable-all suppresses trim
along with everything else). The replacement is **surgical**: set *only* the
trim-end bit and leave every other postprocessing bit clear.
```
RENDER_NORMALIZE = 32768 // ONLY &32768 (trim ending silence). Everything else OFF:
// &1 normalization -> clear
// &64 brickwall limit -> clear
// &512 fade-in / &1024 fade-out -> clear
// &16384 trim starting silence -> clear
// &(1<<16)/&(2<<16) pad start/end -> clear
```
**Why this stays deterministic and un-coloring — the thing the disable-all was
protecting.** The disable-all bit existed to guarantee no normalize/brickwall/fade
touches the signal (those are level-dependent and would break bit-identical repeats
and the null test). A **fixed-threshold trailing-silence trim does none of that**:
it does not scale, limit, fade, or reshape any sample — it only *chooses where the
file ends* by finding the last sample above a fixed -72 dB threshold. Every retained
sample is bit-identical to a no-trim render of the same tail. Because the threshold
is fixed (not derived from the signal's own level, the way normalize is), two
identical requests trim at the identical sample → **bit-identical repeats hold, and
the trimmed region is exactly the region that was below -72 dB anyway**. The trim is
a boundary decision, not a signal transform. So the surgical normalize reintroduces
**none** of the coloring the disable-all was guarding against — it only re-enables
the one bit that is a pure boundary operation.
**`RENDER_TRIMEND` threshold scaling.** The header (line 3062) documents
`RENDER_TRIMEND` as a linear amplitude ratio: "0.5 means -6.02 dB." So the value is
`10^(dB/20)`. For -72 dB:
```
RENDER_TRIMEND = 10^(-72/20) = 10^(-3.6) ≈ 0.00025119
```
Store this as a derived constant from the named -72 dB (see Named constants), not a
magic float — deriving it keeps the dB the single source of truth and lets a future
config change the dB without hand-recomputing the ratio.
> **DAW-confirm.** `RENDER_TRIMEND`'s scaling is documented as amplitude ratio
> (matching `RENDER_NORMALIZE_TARGET` / `RENDER_BRICKWALL`, both "0.5 = -6.02 dB").
> Confirm in a live REAPER that `0.00025119` trims at ≈-72 dB (not, say, -72 dB
> interpreted on a different curve). Cheap to verify: render a decaying reverb tail
> and inspect where the file ends.
### Mode 2 — Manual override: fixed tail, no trim
The existing (currently unwired) `CaptureRequest.renderTail` / `tailMs` fields
(`capture.h` ~L6364) drive this directly:
| Setting | Value |
|---|---|
| `RENDER_TAILFLAG` | `4` |
| `RENDER_TAILMS` | `request.tailMs` (clamped to the 8 s cap — see below) |
| `RENDER_NORMALIZE` | `262144` (`kNormalizeDisableAll`, unchanged) |
| `RENDER_TRIMEND` | not set / irrelevant (trim bit is clear) |
Fixed tail = no trim, so manual mode keeps the disable-all normalize exactly as
today's no-tail path does. The file is exactly `[start, end + tailMs]` of rendered
audio, unprocessed. **Clamp `tailMs` to the 8 s cap** even in manual mode — the cap
is a runaway guard against a non-decaying signal rendering forever, and that risk
exists whether the tail length came from the auto default or an explicit request.
(If a user genuinely needs > 8 s of held tail, that is a reason to revisit the cap
as configurable — noted below — not to let a single request uncap it.)
### The three tail states, unified
The request already has `renderTail: bool` + `tailMs: double`. The auto-trim mode
adds a third state, so the wiring is a small enum, not a bool:
- **None** (default for null-test / verify captures, and the current two-scope
action defaults): `RENDER_TAILFLAG = 0`, `RENDER_TAILMS = 0`, normalize =
disable-all. Exact bounds. Byte-identical to today.
- **Auto** (the new user-facing default for tail-on captures): tailFlag `4`,
tailMs `8000`, normalize `32768` (surgical trim), trimEnd `0.00025119`.
- **Manual(ms)**: tailFlag `4`, tailMs `clamp(ms, 8000)`, normalize `262144`
(disable-all), no trim.
Recommended shape: replace `bool renderTail` with a `TailMode { None, Auto,
Manual }` and keep `tailMs` meaningful only for `Manual`. (Implementation detail
for staff-engineer; the three states above are the contract.) The pure
range/settings decisions — clamp, mode→(`RENDER_*` values) mapping — belong in
`render_settings.{h,cpp}` next to `renderSettingsFor`, so they are unit-tested
outside the DAW exactly like the source-bit mapping is today; the backend just
applies the returned values.
---
## The realtime path (parallel, follow-on)
The M8 `RealtimeRecordBackend` (`src/capture_realtime.cpp`) does **not** drive
`RENDER_*` at all — it taps the selected track's own output (post-fader, pre-parent)
into a hidden temp track over a time selection (track scope only). So none of the offline tail machinery reaches it. It needs a **parallel**
tail path, and it is explicitly a **follow-on to the offline tail** (offline lands
first; realtime tail is a later increment).
**Recipe:**
1. **Record a generous tail window.** Extend the recorded range end by the 8 s cap:
set the record time selection to `[start, end + 8 s]` instead of `[start, end]`
(`capture_realtime.cpp` ~L481482, where `re = request.endSeconds` today). The
transport runs the extra 8 s and the temp track captures the decaying tail.
2. **PCM decay-scan trim.** After the file flushes (the existing deferred-finalize
flush wait), read the recorded PCM and scan **backward** from the end to find the
last frame whose absolute level crosses **-72 dB**; truncate the file at that
frame (rounded to a frame boundary, all channels). If no frame in the tail window
exceeds -72 dB after the range end, trim back to the original `end`. If the signal
never falls below -72 dB within the 8 s window, keep the full window (the cap did
its job).
3. **Manual override** mirrors offline: record `[start, end + clamp(ms, 8 s)]` and
**skip** the decay-scan (fixed tail, no trim).
**Reuse `peaks` for the scan — but note the gap.** `peaks::computeEnvelope`
(`src/peaks.h`) already computes per-channel min/max over interleaved float PCM
without folding channels — the right level primitive. But it needs *frames in
memory*, and it computes an envelope over bins, not a "last frame above threshold"
index. Two honest options for staff-engineer, surfaced not pre-decided:
- **(a) A thin new pure helper** alongside `peaks`: `lastFrameAboveThreshold(interleaved,
channelCount, frameCount, linearThreshold) -> frameIndex`, scanning backward,
taking the max abs across channels per frame (no fold — just the per-frame peak
used for the threshold test). Pure, unit-testable with a synthetic decaying ramp,
mirrors the `peaks` discipline. **Recommended** — it is ~15 lines and exactly the
operation; bending `computeEnvelope` (bin-oriented) to answer a boundary question
is a worse fit.
- **(b) Reuse `computeEnvelope`** at a fine bin resolution and walk the bins
backward for the last bin whose |min|/|max| exceeds threshold, then trim at that
bin's frame span. Coarser (bin-granular, not frame-exact) and re-purposes a
thumbnail tool for a trim decision — not recommended, but avoids a new symbol.
The **file read + truncate** is REAPER-facing (it lives in the realtime shell, not
pure) — reading the recorded wav's PCM into a float buffer and rewriting it
truncated. That is new I/O the realtime backend does not do today (it only
moves/renames the file). Flagged as real work, not a wiring change.
**Realtime is non-deterministic regardless.** M8 is already documented as
non-bit-identical by nature (it is a live record). The decay-scan trim does not
change that: even the trim *index* can vary run-to-run because the recorded samples
vary. This is fine and expected — the realtime tail is a convenience, not a
precision path. The **offline auto-trim is the deterministic one**; realtime is not
held to bit-identical repeats.
---
## Invariant interactions (state these plainly)
- **Opt-in beyond the region.** The tail only ever adds audio past the range end
when a tail is explicitly requested (Auto or Manual). This is exactly the existing
invariant: *"no added silence unless a tail is explicitly requested"*
(`CLAUDE.md §Precision invariants §Exact bounds`). Auto-trim
strengthens it — the tail added is decay, not silence, and the silence past the
decay is trimmed off.
- **The null test uses NO tail.** The null-test / verify capture (M10) and any
bit-identical-repeat verification must run **TailMode::None** — exact bounds. A
dry no-tail capture re-inserted at its source position must null against the
source; a tail would extend the file past the source region and break the null.
**The tail must be OFF for null-test and verification captures** — this is a hard
rule, not a default. (The two capture *actions* may default to a tail once
this ships; the *verify* action never does.)
- **Determinism holds for offline.** The offline auto-trim path is deterministic:
fixed threshold + fixed cap + a boundary-only trim = two identical requests
produce bit-identical files (§surgical normalize argument above). Realtime is
inherently non-deterministic and is not held to this.
- **FX-scope bypass composes correctly — the tail is the right decay per scope.**
The tail render still goes through `FxBypassGuard` for track/item scopes
(`render_settings.h §FxBypassPlan`, `CLAUDE.md §Capture FX scope`). This is
exactly what we want: the tail is the *in-scope FX decay*.
- **Track scope:** the track's own FX are in scope, ancestors/master bypassed →
the tail is **the track's own reverb/delay decay**, not the parent bus's. A track
with a reverb plugin captures that reverb's tail; a track feeding a folder reverb
does **not** capture the folder reverb's tail (that send is out of scope — and
note the pre-existing send-isolation caveat, which the tail inherits
unchanged, does not worsen). Correct and consistent.
- **Item scope:** item/take FX only, self-track + ancestors + master bypassed →
the tail is the **item/take FX decay only**. An item with a take reverb captures
its tail; the track's reverb does not ring into it. Correct.
In every scope the captured tail is precisely the decay of the FX that scope
hears — the FX-scope invariant already guarantees this, and the tail just lets that
decay finish instead of being cut at the range end.
---
## Named constants
Both are named constants, defined once in the pure layer (`render_settings.h`,
next to the render-bit constants), so the offline and realtime paths share one
source of truth:
```cpp
// Auto-trim trailing-silence threshold. -72 dB is quiet enough that the trimmed
// region is inaudible decay, loud enough to not chase a reverb's infinite noise
// floor. Daniel-set.
inline constexpr double kAutoTrimThresholdDb = -72.0;
// Derived linear amplitude ratio for RENDER_TRIMEND (header line 3062: "0.5 = -6dB",
// i.e. 10^(dB/20)). Do not hardcode the ratio — derive it so the dB stays the source
// of truth. ≈ 0.00025119.
// (constexpr pow is C++26; until then compute once at first use or precompute with
// a comment showing the arithmetic — implementation detail.)
// Max tail rendered/recorded past the range end. The runaway guard: a non-decaying
// or looping signal never crosses the trim threshold, so this caps the render.
// Daniel-set.
inline constexpr double kMaxTailSeconds = 8.0;
inline constexpr double kMaxTailMs = 8000.0;
```
**Configurable later?** Both are fixed constants now (YAGNI — no user has asked to
tune them, and a precision tool benefits from predictable defaults). Two realistic
futures to leave room for, not build:
- **-72 dB threshold** → a per-capture or global "tail trim floor" setting, if users
find -72 dB too aggressive (cuts a long reverb early) or too lax (leaves audible
hiss). Low likelihood; the value is deliberately conservative.
- **8 s cap** → a global "max tail" ceiling, if someone captures long orchestral or
ambient tails that genuinely exceed 8 s. More likely than the threshold to be
raised. Keeping it a single named constant makes promoting it to a setting a
one-line change plus a UI affordance.
Neither is in scope now; both are single-constant seams so promotion is cheap.
---
## Acceptance criteria
**Offline — automatic (default):**
- A capture of a range ending mid-reverb, with Auto tail, produces a file whose
audio extends past the range end and ends where the reverb decays below -72 dB
(not at a hard 8 s, and not at the range end).
- A non-decaying signal (sustained pad / loop) with Auto tail produces a file capped
at exactly range + 8 s (the cap fired; nothing trimmed).
- **Bit-identical repeats hold under Auto tail**: two identical Auto-tail requests
produce byte-identical files (the trim is deterministic).
- A capture with **TailMode::None** is byte-identical to the pre-tail exact-bounds
capture of the same range (no regression to the existing path).
**Offline — manual override:**
- A Manual(N ms) capture produces a file of exactly range + N ms of rendered audio,
untrimmed, for `N ≤ 8000`.
- A Manual request with `N > 8000` is clamped to 8000 ms.
**FX-scope composition:**
- Track-scope Auto capture of a track with its own reverb captures that reverb's
tail; the same track's parent-bus reverb does **not** ring into the tail.
- Item-scope Auto capture captures take-FX decay only.
**Realtime (follow-on):**
- A realtime Auto capture of a decaying source records ≥ the range then trims the
file at the -72 dB decay point (± the inherent realtime tolerance).
- Realtime tail is **not** asserted bit-identical (documented non-determinism).
**Invariants (regression gate):**
- The null-test / verify capture runs TailMode::None and still nulls to silence.
- `ScopedRenderSettings` restores `RENDER_NORMALIZE` (and every touched setting) to
the user's prior value on every path, including the new surgical-normalize path —
the user's project render config is untouched after a tail capture.
---
## Open questions / DAW-confirm items
- **`RENDER_TRIMEND` curve (DAW-confirm).** Confirm `0.00025119` trims at ≈-72 dB in
a live REAPER (documented as amplitude ratio, line 3062 — matches
`RENDER_NORMALIZE_TARGET` / `RENDER_BRICKWALL`, but verify against a real decaying
render). Highest-value confirm — the whole auto mode rides on it.
- **Surgical-normalize interaction (DAW-confirm).** Confirm `RENDER_NORMALIZE =
32768` (only trim-end) trims trailing silence **without** engaging any normalize /
fade / pad behavior — i.e. the other bits being clear genuinely means "off," not
"default on." The header bit layout says so (line 3051); confirm empirically that
a trim-end-only render does not, e.g., apply a default fade-out.
- **Trim vs. exact end boundary.** Confirm the trim never eats into audio *before*
the range end — i.e. `RENDER_TRIMEND` trims only the rendered **tail's** trailing
silence, never the pre-`ENDPOS` body. (It should: trim-end operates on the file's
trailing edge, and the tail is appended after `ENDPOS`. But a range that itself
ends in near-silence before a loud transient is the edge case to check the trim
doesn't over-eat.)
- **Auto as the action default? — DECIDED (2026-07-23).** The two shipped capture
actions (`CAPTURE_ITEM` / `CAPTURE_TRACK`) stay `TailMode::None` by default; the
tail mode is exposed as a **settings toggle in the docked bank panel** (None / Auto
/ Manual — the footer strip, `bank_panel.cpp` + pure `tail_control`), and the plain
capture actions READ that toggle when building the `CaptureRequest`. Chosen over the
earlier "…with tail" paired-action lean: one toggle covers all three states without
doubling the action count, and the exact-bounds contract still holds because the
toggle defaults to None. The setting is an extension-session setting (default None;
persists across project loads and panel open/close within a REAPER session; resets to
None only on extension unload — i.e. fresh REAPER session; project persistence across
REAPER restarts is a follow-on). Manual ships a fixed 2 s default; a
fine-adjust affordance (+/- click zones or scroll) is a follow-on. The verify /
null-test capture still always runs `TailMode::None` regardless of the toggle.
- **Realtime tail sequencing.** Confirmed a **follow-on** to the offline tail — do
not block offline on it. Filed as a separate PLAN point.