The retired custom-bounds premise is corrected wherever it was encoded: the tail bit is the time selection's, not custom bounds'.
23 KiB
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 M0–M11, Design View, Multi-bank) keeps its authoritative technical spec as a per-directory
src/**/CLAUDE.mdsection and its why in adocs/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 rootCLAUDE.md§Precision invariants andsrc/core/capture/CLAUDE.md/src/shell/capture/CLAUDE.mdas landed history. Same standing discipline applies: verify every REAPER API name/flag againstvendor/reaper-sdk/sdk/reaper_plugin_functions.hbefore 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 ~L470–477;
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_TAILFLAGbit per capture range type (time selection vs. razor vs. item) and flagged&32as "markers/regions." The header (line 3047) says&32= selected project regions and&8= all markers/regions — but neither matters: our renders are allRENDER_BOUNDSFLAG = 2, so the tail bit is&4unconditionally. The existingkTailFlagTimeSelection = 4constant insrc/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 (matchingRENDER_NORMALIZE_TARGET/RENDER_BRICKWALL, both "0.5 = -6.02 dB"). Confirm in a live REAPER that0.00025119trims 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 ~L63–64) 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, tailMs8000, normalize32768(surgical trim), trimEnd0.00025119. - Manual(ms): tailFlag
4, tailMsclamp(ms, 8000), normalize262144(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:
- 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~L481–482, wherere = request.endSecondstoday). The transport runs the extra 8 s and the temp track captures the decaying tail. - 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). - 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 thepeaksdiscipline. Recommended — it is ~15 lines and exactly the operation; bendingcomputeEnvelope(bin-oriented) to answer a boundary question is a worse fit. - (b) Reuse
computeEnvelopeat 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
FxBypassGuardfor 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:
// 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 > 8000is 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.
ScopedRenderSettingsrestoresRENDER_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_TRIMENDcurve (DAW-confirm). Confirm0.00025119trims at ≈-72 dB in a live REAPER (documented as amplitude ratio, line 3062 — matchesRENDER_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_TRIMENDtrims only the rendered tail's trailing silence, never the pre-ENDPOSbody. (It should: trim-end operates on the file's trailing edge, and the tail is appended afterENDPOS. 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) stayTailMode::Noneby default; the tail mode is exposed as a settings toggle in the docked bank panel (None / Auto / Manual — the footer strip,bank_panel.cpp+ puretail_control), and the plain capture actions READ that toggle when building theCaptureRequest. 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 runsTailMode::Noneregardless 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.