Files
reasampler/docs/product/capture-tail.md
T
daniel 3791e6c119 docs: spec capture-tail feature (Milestone T)
Add docs/product/capture-tail.md (authoritative spec) and Milestone T in PLAN.md:
offline auto-trim (-72 dB) + manual tail modes, surgical RENDER_NORMALIZE
(trim-end-only), realtime PCM-decay-scan follow-on, invariant interactions,
and DAW-confirm items.
2026-07-23 15:38:16 -04:00

22 KiB
Raw Blame History

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 lives in PLAN.md (Milestone T); this doc holds the full technical detail and the product framing.

Why this doc carries the technical spec (not CONTEXT.md). Every other pillar (capture M0M11, Design View, Multi-bank) keeps its authoritative technical spec as a CONTEXT.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 is being specced without reopening CONTEXT.md. So the authoritative detail lands here, house-styled to match the CONTEXT specs; when the tail work lands, doc-keeper may fold the invariant deltas into CONTEXT.md §Precision invariants 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 custom — so the tail bit is always &1

The backend renders with RENDER_BOUNDSFLAG = 0 (custom time bounds) for every scope and every range type: it sets RENDER_STARTPOS / RENDER_ENDPOS explicitly from the request's exact seconds (capture.cpp ~L352354). It does not use the time-selection / selected-items / regions bounds modes.

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 custom-time-bounds mode, the only tail bit that ever applies is &1. There is no per-range-type tail-flag decision to make — a razor capture, a time-selection capture, and an item capture are all custom-bounds renders under the hood, so all three take RENDER_TAILFLAG = 1.

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 = 0, so the tail bit is &1 unconditionally. The existing kTailFlagCustomBounds = 1.0 constant in capture.cpp (~L80) is already correct; the field wiring is what's missing.

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 1 apply tail for custom time bounds (line 3047, &1)
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 1
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 three-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 1, tailMs 8000, normalize 32768 (surgical trim), trimEnd 0.00025119.
  • Manual(ms): tailFlag 1, 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 records the master send into a hidden temp track over a time selection. 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" (CONTEXT.md §Precision invariants, CLAUDE.md §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 three-scope 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.

    • Master scope: whole chain in scope → the tail is the full master-chain reverb/release decay. Correct.
    • 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 in PLAN.md §Open questions, 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:

// 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? Should the three shipped capture actions (CAPTURE_ITEM / CAPTURE_TRACK / CAPTURE_MASTER, currently all TailMode::None per render_settings.cpp §captureActionTable) flip to Auto tail by default once this ships, or should tail be a separate action variant / a modifier? Product call for Daniel. Leaning: a per-action-family toggle or a paired "…​with tail" variant rather than silently changing the existing actions' behavior — the current exact-bounds default is a documented contract and some captures (chops, wavetable grabs) want no tail. Not blocking the offline implementation; the request-level contract (three tail states) is independent of which action sets which.
  • Realtime tail sequencing. Confirmed a follow-on to the offline tail — do not block offline on it. Filed as a separate PLAN point.