Replace the ...-with-tail variant actions with a pure tail_control module + a docked-panel footer toggle (Off/Auto/Manual) read by CAPTURE_ITEM/TRACK. Default None (byte-identical); Manual fixed 2s, in-memory session lifetime. Tail mechanism unchanged; retired the variant ids.
22 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 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 M0–M11, Design View, Multi-bank) keeps its authoritative technical spec as aCONTEXT.md §section 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 is being specced without reopeningCONTEXT.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 intoCONTEXT.md §Precision invariantsas 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 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 ~L352–354). 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_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 = 0, so the tail bit is&1unconditionally. The existingkTailFlagCustomBounds = 1.0constant incapture.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 (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 |
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 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
1, tailMs8000, normalize32768(surgical trim), trimEnd0.00025119. - Manual(ms): tailFlag
1, 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" (
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 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 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.
- 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
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.