feat(capture): offline auto/manual tail via pure TailMode mapping

Add TailMode{None,Auto,Manual} + derived -72dB TRIMEND + 8s cap in render_settings;
wire into OfflineRenderBackend with snapshot/restore of the tail settings; add
CAPTURE_ITEM_TAIL/CAPTURE_TRACK_TAIL variant actions (plain actions stay exact-bounds).
This commit is contained in:
2026-07-23 17:08:24 -04:00
parent 92762e2b6c
commit 2d225258b4
6 changed files with 313 additions and 63 deletions
+31 -23
View File
@@ -74,10 +74,9 @@ constexpr int kActionRenderUsingMostRecentSettings = 42230;
// ourselves for exact, unrounded bounds). Verified: SDK header line ~3042.
constexpr double kBoundsCustom = 0.0;
// RENDER_TAILFLAG bit &1 = apply tail for custom time bounds. We clear it for
// the spike (exact bounds, no added silence — precision invariant).
constexpr double kTailFlagNone = 0.0;
constexpr double kTailFlagCustomBounds = 1.0; // &1, used only if renderTail set
// RENDER_TAILFLAG / RENDER_TAILMS / RENDER_NORMALIZE / RENDER_TRIMEND for the tail
// are driven from the pure tailRenderSettingsFor mapping (render_settings.h),
// unit-tested outside the DAW. See the tail-driving block in capture() below.
// RENDER_DITHER disable-all: &16 = disable all dither/noise-shaping.
// Verified: SDK header line ~3050: "&16=disable all".
@@ -85,12 +84,6 @@ constexpr double kTailFlagCustomBounds = 1.0; // &1, used only if renderTail se
// enabled the render would obey it, breaking bit-identical repeats. Force off.
constexpr double kDitherDisableAll = 16.0;
// RENDER_NORMALIZE disable-all: &(4<<16) = disable all render postprocessing.
// Verified: SDK header line ~3051: "(&(4<<16))==disable all render postprocessing".
// This masks out normalization, brickwall, fades, pad/trim — every post-process
// that is nondeterministic relative to the source signal.
constexpr double kNormalizeDisableAll = static_cast<double>(4 << 16); // 262144
// --- WAV render sink configuration ------------------------------------------
//
// FORMAT CHOICE (CONTEXT.md open question — surfaced for Daniel to confirm):
@@ -147,6 +140,7 @@ struct RenderSettingsSnapshot {
double addToProj = 0.0;
double dither = 0.0; // RENDER_DITHER — snapshotted so user's setting is restored
double normalize = 0.0; // RENDER_NORMALIZE — snapshotted so user's setting is restored
double trimEnd = 0.0; // RENDER_TRIMEND — snapshotted so the Auto trim threshold is restored
// String settings (GetSetProjectInfo_String). Big buffers: REAPER writes the
// full value in, and RENDER_FORMAT is a base64 blob that can be long.
@@ -183,6 +177,7 @@ void snapshotRenderSettings(RenderSettingsSnapshot& s, ReaProject* proj) {
s.addToProj = GetSetProjectInfo(proj, "RENDER_ADDTOPROJ", 0.0, false);
s.dither = GetSetProjectInfo(proj, "RENDER_DITHER", 0.0, false);
s.normalize = GetSetProjectInfo(proj, "RENDER_NORMALIZE", 0.0, false);
s.trimEnd = GetSetProjectInfo(proj, "RENDER_TRIMEND", 0.0, false);
s.renderFile = getProjString(proj, "RENDER_FILE");
s.renderPattern = getProjString(proj, "RENDER_PATTERN");
s.renderFormat = getProjString(proj, "RENDER_FORMAT");
@@ -207,6 +202,7 @@ void restoreRenderSettings(const RenderSettingsSnapshot& s) {
GetSetProjectInfo(s.proj, "RENDER_ADDTOPROJ", s.addToProj, true);
GetSetProjectInfo(s.proj, "RENDER_DITHER", s.dither, true);
GetSetProjectInfo(s.proj, "RENDER_NORMALIZE", s.normalize, true);
GetSetProjectInfo(s.proj, "RENDER_TRIMEND", s.trimEnd, true);
}
// RAII wrapper: guarantees restore on every return path from capture().
@@ -353,13 +349,18 @@ CaptureResult OfflineRenderBackend::capture(const CaptureRequest& request) {
GetSetProjectInfo(proj, "RENDER_STARTPOS", request.startSeconds, true);
GetSetProjectInfo(proj, "RENDER_ENDPOS", request.endSeconds, true);
if (request.renderTail) {
GetSetProjectInfo(proj, "RENDER_TAILFLAG", kTailFlagCustomBounds, true);
GetSetProjectInfo(proj, "RENDER_TAILMS", request.tailMs, true);
} else {
GetSetProjectInfo(proj, "RENDER_TAILFLAG", kTailFlagNone, true);
GetSetProjectInfo(proj, "RENDER_TAILMS", 0.0, true);
}
// Tail: TAILFLAG / TAILMS / NORMALIZE / TRIMEND all come from the pure mapping
// (render_settings.h, unit-tested). None -> exact bounds + disable-all normalize
// (byte-identical to the pre-tail path); Auto -> 8 s tail + surgical trim-end
// normalize + -72 dB TRIMEND; Manual -> clamped fixed tail + disable-all, no trim.
// RENDER_NORMALIZE is driven HERE from the mapping (not the determinism block
// below) so the Auto surgical value is not clobbered — the snapshot guard restores
// the user's original RENDER_NORMALIZE / RENDER_TRIMEND on every exit path.
const TailRenderSettings tail =
tailRenderSettingsFor(request.tailMode, request.tailMs);
GetSetProjectInfo(proj, "RENDER_TAILFLAG",
static_cast<double>(tail.tailFlag), true);
GetSetProjectInfo(proj, "RENDER_TAILMS", tail.tailMs, true);
// Source-selection bits for this mode, from the pure render_settings mapping
// (verified against SDK header ~3041). All M7 actions are wet-only:
@@ -394,13 +395,20 @@ CaptureResult OfflineRenderBackend::capture(const CaptureRequest& request) {
// item. Clearing RENDER_ADDTOPROJ&1 keeps capture out of the arrange.
GetSetProjectInfo(proj, "RENDER_ADDTOPROJ", 0.0, true);
// Determinism: disable dither and all render post-processing so identical
// inputs produce bit-identical files and a dry capture nulls to silence.
// RENDER_DITHER &16 = disable all dither/noise-shaping (SDK header line ~3050).
// RENDER_NORMALIZE &(4<<16) = disable all render postprocessing (line ~3051).
// Both are snapshotted above and restored by the RAII guard on every path.
// Determinism: disable dither so identical inputs produce bit-identical files
// and a dry capture nulls to silence. RENDER_DITHER &16 = disable all dither/
// noise-shaping (SDK header line ~3050). Snapshotted above; restored by the guard.
GetSetProjectInfo(proj, "RENDER_DITHER", kDitherDisableAll, true);
GetSetProjectInfo(proj, "RENDER_NORMALIZE", kNormalizeDisableAll, true);
// RENDER_NORMALIZE + RENDER_TRIMEND come from the tail mapping (above). None /
// Manual -> disable-all (byte-identical to the pre-tail path); Auto -> surgical
// trim-end (only &32768) + the -72 dB TRIMEND. A fixed-threshold trailing-silence
// trim scales/limits/fades nothing, so Auto stays deterministic and un-coloring
// (spec §surgical normalize). TRIMEND is only consulted when the trim bit is set,
// but we write it unconditionally (harmless when clear) so the value is explicit.
GetSetProjectInfo(proj, "RENDER_NORMALIZE",
static_cast<double>(tail.normalize), true);
GetSetProjectInfo(proj, "RENDER_TRIMEND", tail.trimEnd, true);
// Output location: directory (RENDER_FILE) + file stem (RENDER_PATTERN).
// RENDER_PATTERN with no wildcards is a literal stem; REAPER appends the
+8 -4
View File
@@ -20,6 +20,7 @@
#include <vector>
#include "bank_model.h"
#include "render_settings.h" // TailMode (pure) — the three-state tail contract
// MediaTrack is forward-declared (like track_guid.h) so this header stays
// REAPER-free while RealtimeRecordBackend::begin can take the resolved source
@@ -64,10 +65,13 @@ struct CaptureRequest {
// it stays source-agnostic, driven entirely by the request).
std::vector<std::string> trackGuids;
// Render tail. Default OFF for the spike (exact bounds, no added silence —
// precision invariant). M7 makes this bindable.
bool renderTail = false;
double tailMs = 0.0;
// Render tail (docs/product/capture-tail.md §The three tail states). Default
// None: exact bounds, no added silence — the precision invariant, and the only
// mode valid for null-test / verify captures. `tailMs` is meaningful ONLY for
// TailMode::Manual (clamped to the 8 s cap by the pure mapping); Auto uses the
// 8 s cap + -72 dB trim internally, None ignores it.
TailMode tailMode = TailMode::None;
double tailMs = 0.0;
// Output format. 0 sampleRate => follow project rate (deterministic: the
// project rate is fixed for a given project).
+3 -3
View File
@@ -510,8 +510,8 @@ static void RunCapture(const reasampler::CaptureActionDef& def)
req.startSeconds = src.startSeconds; // exact bounds — no rounding
req.endSeconds = src.endSeconds;
req.wetDry = 1.0; // wet post the FX left enabled by the scope
req.renderTail = false; // exact bounds, no tail (default)
req.tailMs = 0.0;
req.tailMode = def.tailMode; // None (exact bounds) or Auto ("…with tail" variant)
req.tailMs = 0.0; // Manual-only; no Manual action yet (future config/UI)
req.sampleRate = 0; // follow project rate
req.channelCount = 2;
req.bitDepth = reasampler::WavBitDepth::Float32; // deterministic, no dither
@@ -590,7 +590,7 @@ static void RunCaptureRealtimeTrack()
req.startSeconds = src.startSeconds; // exact bounds — no rounding
req.endSeconds = src.endSeconds;
req.wetDry = 1.0; // fully wet (post-fader tap)
req.renderTail = false;
req.tailMode = reasampler::TailMode::None; // realtime tail is T2; exact bounds here
req.tailMs = 0.0;
req.sampleRate = 0; // follow project rate
req.channelCount = 2;
+75 -11
View File
@@ -3,10 +3,59 @@
#include "render_settings.h"
#include <algorithm>
#include <cmath>
#include <sstream>
namespace reasampler {
double autoTrimEndRatio() {
// Amplitude ratio = 10^(dB/20). Derived from kAutoTrimThresholdDb so the dB is
// the single source of truth (header ~3062: RENDER_TRIMEND is an amplitude ratio,
// "0.5 means -6.02 dB"). For -72 dB this is ~= 0.00025119.
return std::pow(10.0, kAutoTrimThresholdDb / 20.0);
}
TailRenderSettings tailRenderSettingsFor(TailMode mode, double manualTailMs) {
TailRenderSettings t;
switch (mode) {
case TailMode::None:
// Exact bounds — byte-identical to the pre-tail no-tail capture. Tail off,
// disable-all normalize (the current default), no trim.
t.tailFlag = kTailFlagNone;
t.tailMs = 0.0;
t.normalize = kNormalizeDisableAll;
t.trimEnd = 0.0;
return t;
case TailMode::Auto:
// Generous 8 s tail, then SURGICAL normalize: ONLY the trim-ending-silence
// bit (32768) — every other postprocessing bit clear. A fixed-threshold
// trailing-silence trim is a pure boundary decision (it scales/limits/fades
// nothing), so it re-introduces none of the coloring the disable-all bit
// guarded against, and two identical requests trim at the identical sample
// -> bit-identical repeats hold (spec §surgical normalize).
t.tailFlag = kTailFlagCustomBounds;
t.tailMs = kMaxTailMs;
t.normalize = kNormalizeTrimEnd;
t.trimEnd = autoTrimEndRatio();
return t;
case TailMode::Manual:
// Fixed tail, no trim -> keep the disable-all normalize exactly as the
// no-tail path does. Clamp to the 8 s cap even here: the runaway guard
// applies whether the length came from the Auto default or an explicit
// request (spec §Manual override). Negative requests floor to 0.
t.tailFlag = kTailFlagCustomBounds;
t.tailMs = std::clamp(manualTailMs, 0.0, kMaxTailMs);
t.normalize = kNormalizeDisableAll;
t.trimEnd = 0.0;
return t;
}
// Unreachable for a valid enum; fail closed to exact bounds (never a stray tail).
return t;
}
RenderSettingsChoice renderSettingsFor(SourceMode mode, double /*wetDry*/) {
// `wetDry` is accepted so CaptureRequest.wetDry remains the seam for future
// dry work (M10 null test), but it does not affect this mapping. FX scoping is
@@ -130,23 +179,38 @@ RazorRange razorUnionBounds(const std::vector<RazorRange>& ranges) {
}
const std::vector<CaptureActionDef>& captureActionTable() {
// Built once (function-local static): two SCOPE actions. Tail OFF for all
// (exact bounds). Ids are FOREVER-STABLE — never edit a shipped string. Each
// action infers its range (razor-else-time) at fire time and enforces its
// FX-scope invariant via fxBypassPlanFor. The M7 CAPTURE_TRACKS_WET /
// CAPTURE_ITEMS_WET / CAPTURE_RAZOR_WET ids are RETIRED (mirror-unregistered in
// main.cpp); the CAPTURE_MASTER scope action is REMOVED (its id is likewise
// mirror-unregistered) — to capture the master you render a track.
// Built once (function-local static): two SCOPE actions x two tail variants.
// The None rows are exact bounds (byte-identical to today); the …_TAIL rows are
// the paired TailMode::Auto "…with tail" variants (Daniel's lean over silently
// flipping the exact-bounds default — spec §Open questions). Ids are FOREVER-
// STABLE — never edit a shipped string. Each action infers its range
// (razor-else-time) at fire time and enforces its FX-scope invariant via
// fxBypassPlanFor. The M7 CAPTURE_TRACKS_WET / CAPTURE_ITEMS_WET /
// CAPTURE_RAZOR_WET ids are RETIRED (mirror-unregistered in main.cpp); the
// CAPTURE_MASTER scope action is REMOVED (its id is likewise mirror-
// unregistered) — to capture the master you render a track.
static const std::vector<CaptureActionDef> table = {
// Item scope — item/take FX only. NEW forever-stable id.
// Item scope, exact bounds — item/take FX only.
{"CEREBELLUM_REASAMPLER_CAPTURE_ITEM",
"ReaSampler: capture selected item(s)", "item",
CaptureScope::Item},
CaptureScope::Item, TailMode::None},
// Track scope — item FX + the track's own FX. NEW forever-stable id.
// Track scope, exact bounds — item FX + the track's own FX.
{"CEREBELLUM_REASAMPLER_CAPTURE_TRACK",
"ReaSampler: capture selected track(s)", "track",
CaptureScope::Track},
CaptureScope::Track, TailMode::None},
// Item scope with Auto tail — captures the take-FX decay past the range end,
// trimmed to -72 dB. NEW forever-stable id.
{"CEREBELLUM_REASAMPLER_CAPTURE_ITEM_TAIL",
"ReaSampler: capture selected item(s) with tail", "item",
CaptureScope::Item, TailMode::Auto},
// Track scope with Auto tail — captures the track's own reverb/delay decay
// past the range end, trimmed to -72 dB. NEW forever-stable id.
{"CEREBELLUM_REASAMPLER_CAPTURE_TRACK_TAIL",
"ReaSampler: capture selected track(s) with tail", "track",
CaptureScope::Track, TailMode::Auto},
};
return table;
}
+81 -7
View File
@@ -45,6 +45,75 @@ inline constexpr int kRenderRazorEdits = 4096; // &4096 render razor e
// (post the FX that remain enabled); the scope decides which FX remain enabled.
inline constexpr int kRenderSingleFile = (4 << 16); // items/razor -> one file
// --- Tail: RENDER_NORMALIZE / RENDER_TRIMEND bits + named constants ----------
//
// The capture-tail feature (docs/product/capture-tail.md) preserves reverb/release
// decay past the range end. Every offline capture renders custom-time-bounds, so
// the only tail-flag bit that ever applies is &1 (RENDER_TAILFLAG, header ~3047).
// These values are the pure part — mode -> (RENDER_* values) — unit-tested outside
// the DAW exactly like renderSettingsFor; the backend just applies them.
//
// RENDER_NORMALIZE bit meanings (verbatim from SDK header ~3051):
// &32768 = trim ending silence (the surgical Auto path)
// &(4<<16) = disable all render postprocessing (the None/Manual path)
inline constexpr int kNormalizeTrimEnd = 32768; // &32768 trim ending silence
inline constexpr int kNormalizeDisableAll = (4 << 16); // &(4<<16) = 262144, disable all
// RENDER_TAILFLAG &1 = apply tail for custom time bounds (header ~3047). We render
// custom bounds unconditionally, so this is the only tail bit that ever applies.
inline constexpr int kTailFlagNone = 0;
inline constexpr int kTailFlagCustomBounds = 1; // &1
// 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. Single source of truth: the RENDER_TRIMEND ratio derives from
// this dB, never the reverse.
inline constexpr double kAutoTrimThresholdDb = -72.0;
// Max tail rendered 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. Shared by the offline (T1) and future realtime (T2) tail paths.
inline constexpr double kMaxTailSeconds = 8.0;
inline constexpr double kMaxTailMs = 8000.0;
// Derived linear amplitude ratio for RENDER_TRIMEND. The header (~3062) documents
// RENDER_TRIMEND as an amplitude ratio ("0.5 means -6.02 dB"), i.e. 10^(dB/20).
// Derived from kAutoTrimThresholdDb so the dB stays the single source of truth and
// a future config change to the dB does not require hand-recomputing the ratio.
//
// std::pow is not constexpr before C++26, so this is a function, not a constant.
// For -72 dB: 10^(-72/20) = 10^(-3.6) ~= 0.00025119 (the value the DAW confirm targets).
double autoTrimEndRatio();
// The three tail states (docs/product/capture-tail.md §The three tail states):
// None — exact bounds, no tail. Byte-identical to the pre-tail capture. The
// default and the ONLY mode for null-test / verify captures.
// Auto — generous 8 s tail then trim trailing silence to -72 dB (surgical
// normalize). The user-facing "…with tail" default.
// Manual — a fixed tail length (clamped to the 8 s cap), no trim.
enum class TailMode {
None,
Auto,
Manual,
};
// The RENDER_* values a tail mode drives, in addition to the exact STARTPOS/ENDPOS
// the backend already sets. `trimEnd` is meaningful only when the trim-end normalize
// bit is set (Auto); it is 0 otherwise. This is the pure mapping — the backend reads
// these four fields straight onto GetSetProjectInfo.
struct TailRenderSettings {
int tailFlag = kTailFlagNone; // RENDER_TAILFLAG (0 or &1)
double tailMs = 0.0; // RENDER_TAILMS
int normalize = kNormalizeDisableAll; // RENDER_NORMALIZE
double trimEnd = 0.0; // RENDER_TRIMEND (only used when trim bit set)
};
// Maps a tail mode (+ the requested manual tail ms) to its RENDER_* values.
// `manualTailMs` is used ONLY for TailMode::Manual (ignored otherwise). Manual is
// clamped to kMaxTailMs — the runaway guard applies whether the length came from
// the Auto default or an explicit request (spec §Manual override). Pure + tested.
TailRenderSettings tailRenderSettingsFor(TailMode mode, double manualTailMs);
// The RENDER_SETTINGS value for a given source mode. `supported` is false only
// for SourceMode::Realtime (that is the M8 backend, not offline render).
struct RenderSettingsChoice {
@@ -144,10 +213,13 @@ RazorRange razorUnionBounds(const std::vector<RazorRange>& ranges);
// --- Capture-action taxonomy (the bindable set main.cpp registers) -----------
//
// One row per bindable SCOPE action. Two scopes (item / track); the
// range each captures (razor-else-time) is inferred at fire time, not a mode.
// Tail is OFF for every row (exact bounds); a tail-on variant is a later opt-in,
// YAGNI now. Bounded, discoverable, NO dialogs (the tool's no-clutter ethos).
// One row per bindable SCOPE action. Two scopes (item / track), each in two tail
// variants: an exact-bounds row (TailMode::None) and a paired "…with tail" row
// (TailMode::Auto). The range each captures (razor-else-time) is inferred at fire
// time, not a mode. The tail variants are additive — the None rows keep their
// documented exact-bounds contract byte-for-byte; the Auto variants are a separate
// opt-in action rather than a silent default flip (Daniel's lean, spec §Open
// questions). Bounded, discoverable, NO dialogs (the tool's no-clutter ethos).
//
// commandString is FOREVER-STABLE (user keybindings key off it) — never change a
// shipped value. baseName feeds the file stem (sanitized by capture_paths).
@@ -156,15 +228,17 @@ struct CaptureActionDef {
const char* description; // Actions-list label
const char* baseName; // file-stem base for this capture
CaptureScope scope; // FX scope (item / track)
TailMode tailMode; // None (exact bounds) or Auto ("…with tail")
};
// The capture-action table. Iterated by main.cpp to register the family and route
// each fired command back to its definition. Kept here (pure) so the taxonomy is
// one testable list, not scattered registration code.
//
// Two scope rows: CAPTURE_ITEM, CAPTURE_TRACK. There is no master capture — to
// capture the master you render a track. Razor is an inferred range, not a mode,
// and each scope enforces its FX-scope invariant via fxBypassPlanFor.
// Four rows: CAPTURE_ITEM / CAPTURE_TRACK (exact bounds, TailMode::None) plus the
// paired CAPTURE_ITEM_TAIL / CAPTURE_TRACK_TAIL variants (TailMode::Auto). There is
// no master capture — to capture the master you render a track. Razor is an inferred
// range, not a mode, and each scope enforces its FX-scope invariant via fxBypassPlanFor.
const std::vector<CaptureActionDef>& captureActionTable();
} // namespace reasampler
+115 -15
View File
@@ -1,12 +1,14 @@
// Standalone tests for reasampler::render_settings — no REAPER, no framework.
// Covers the pure pieces behind the two-scope capture family: the source-mode ->
// RENDER_SETTINGS bit mapping, P_RAZOREDITS parsing -> ranges + union, scope ->
// source mode, range inference (razor-else-time), the FX-bypass plan (corrects
// the "items captured through parent FX" defect), and the capture-action taxonomy
// table (stable ids, one row per scope).
// Covers the pure pieces behind the capture family: the source-mode ->
// RENDER_SETTINGS bit mapping, the TailMode -> RENDER_* (tail/normalize/trim-end)
// mapping + the -72 dB derived ratio + the 8 s manual clamp, P_RAZOREDITS parsing
// -> ranges + union, scope -> source mode, range inference (razor-else-time), the
// FX-bypass plan (corrects the "items captured through parent FX" defect), and the
// capture-action taxonomy table (stable ids, scope x tail-variant matrix).
#include "../src/render_settings.h"
#include <cmath>
#include <cstdio>
#include <set>
#include <string>
@@ -60,6 +62,70 @@ static void testRealtimeIsUnsupportedOffline() {
CHECK(!renderSettingsFor(SourceMode::Realtime, 1.0).supported);
}
// --- tail: TailMode -> RENDER_* mapping (docs/product/capture-tail.md) --------
static void testTailNoneIsExactBounds() {
// None -> exact bounds, byte-identical to the pre-tail capture: tail flag clear,
// 0 ms, disable-all normalize (the current default), no trim. Asserting the exact
// bit values (not just "some value") pins the byte-identical contract: if the
// mapping regressed to set a tail bit or a non-disable-all normalize, this fails.
TailRenderSettings t = tailRenderSettingsFor(TailMode::None, 0.0);
CHECK(t.tailFlag == kTailFlagNone); // 0
CHECK(t.tailMs == 0.0);
CHECK(t.normalize == kNormalizeDisableAll); // 262144
CHECK(t.trimEnd == 0.0);
// manualTailMs must be ignored for None (a stray tail from a leftover ms is the bug).
TailRenderSettings t2 = tailRenderSettingsFor(TailMode::None, 5000.0);
CHECK(t2.tailFlag == kTailFlagNone);
CHECK(t2.tailMs == 0.0);
}
static void testTailAutoIsSurgicalTrim() {
// Auto -> custom-bounds tail bit, 8 s cap, SURGICAL normalize (ONLY &32768), and
// the -72 dB TRIMEND ratio. The disable-all bit must NOT be set (it is semantically
// opposed to trim — this assertion catches a regression to the None normalize).
TailRenderSettings t = tailRenderSettingsFor(TailMode::Auto, 0.0);
CHECK(t.tailFlag == kTailFlagCustomBounds); // &1
CHECK(t.tailMs == kMaxTailMs); // 8000
CHECK(t.normalize == kNormalizeTrimEnd); // exactly 32768, nothing else
CHECK((t.normalize & kNormalizeDisableAll) == 0); // disable-all is NOT set
// TRIMEND is the derived -72 dB ratio ~= 0.00025119 (the DAW-confirm value).
CHECK(std::fabs(t.trimEnd - 0.00025119) < 1e-8);
// manualTailMs is ignored for Auto (Auto always uses the 8 s cap).
CHECK(tailRenderSettingsFor(TailMode::Auto, 3000.0).tailMs == kMaxTailMs);
}
static void testAutoTrimRatioDerivesFromDb() {
// The ratio must DERIVE from the -72 dB constant (10^(dB/20)), not be a hardcoded
// float — recompute it independently and require an exact match with the mapping.
double expected = std::pow(10.0, kAutoTrimThresholdDb / 20.0);
CHECK(autoTrimEndRatio() == expected);
CHECK(tailRenderSettingsFor(TailMode::Auto, 0.0).trimEnd == expected);
// Sanity: -72 dB is well below unity but above zero.
CHECK(expected > 0.0 && expected < 0.001);
}
static void testTailManualFixedNoTrim() {
// Manual -> custom-bounds tail, the requested ms (within cap), disable-all
// normalize (no trim). A Manual capture is a fixed tail, so it keeps today's
// disable-all exactly like the no-tail path.
TailRenderSettings t = tailRenderSettingsFor(TailMode::Manual, 2500.0);
CHECK(t.tailFlag == kTailFlagCustomBounds);
CHECK(t.tailMs == 2500.0);
CHECK(t.normalize == kNormalizeDisableAll);
CHECK(t.trimEnd == 0.0);
}
static void testTailManualClampsToCap() {
// The 8 s cap is a runaway guard that applies to Manual too: ms > 8000 -> 8000.
CHECK(tailRenderSettingsFor(TailMode::Manual, 9000.0).tailMs == kMaxTailMs);
CHECK(tailRenderSettingsFor(TailMode::Manual, 8000.0).tailMs == kMaxTailMs);
// Below the cap is passed through unchanged.
CHECK(tailRenderSettingsFor(TailMode::Manual, 100.0).tailMs == 100.0);
// A negative request floors to 0 (no negative tail leaks into RENDER_TAILMS).
CHECK(tailRenderSettingsFor(TailMode::Manual, -50.0).tailMs == 0.0);
}
// --- parseRazorEdits: P_RAZOREDITS string -> ranges --------------------------
static void testParseSingleTrackAudioArea() {
@@ -150,15 +216,17 @@ static void testTrackScopeKeepsSelfBypassesAncestorsAndMaster() {
CHECK(p.bypassMaster); // no master FX
}
// --- captureActionTable: the two-scope taxonomy ------------------------------
// --- captureActionTable: the scope x tail-variant taxonomy -------------------
static void testTableHasTwoScopeRows() {
static void testTableHasScopeAndTailVariants() {
const auto& table = captureActionTable();
// Exactly 2 scope rows: item, track. There is no master scope.
CHECK(table.size() == 2);
// Four rows: item/track x (None exact-bounds, Auto "…with tail"). No master scope.
CHECK(table.size() == 4);
std::set<std::string> ids;
bool sawItem = false, sawTrack = false;
// Count each (scope, tailMode) pairing so we assert the FULL matrix is present,
// not merely that some item + some track row exist.
int itemNone = 0, itemAuto = 0, trackNone = 0, trackAuto = 0;
for (const auto& def : table) {
// Every id is a non-empty CEREBELLUM_REASAMPLER_ string and is UNIQUE
// (duplicate ids would collide on registration).
@@ -167,12 +235,38 @@ static void testTableHasTwoScopeRows() {
CHECK(ids.insert(id).second); // false if duplicate
// Every scope resolves to a supported offline source.
CHECK(renderSettingsFor(sourceModeForScope(def.scope), 1.0).supported);
// Tail variants only ever ship None or Auto (Manual has no dedicated action yet).
CHECK(def.tailMode == TailMode::None || def.tailMode == TailMode::Auto);
if (def.scope == CaptureScope::Item) sawItem = true;
if (def.scope == CaptureScope::Track) sawTrack = true;
if (def.scope == CaptureScope::Item && def.tailMode == TailMode::None) ++itemNone;
if (def.scope == CaptureScope::Item && def.tailMode == TailMode::Auto) ++itemAuto;
if (def.scope == CaptureScope::Track && def.tailMode == TailMode::None) ++trackNone;
if (def.scope == CaptureScope::Track && def.tailMode == TailMode::Auto) ++trackAuto;
}
CHECK(sawItem);
CHECK(sawTrack);
// Exactly one row per (scope, tail) cell — the full 2x2 matrix, no dupes/gaps.
CHECK(itemNone == 1);
CHECK(itemAuto == 1);
CHECK(trackNone == 1);
CHECK(trackAuto == 1);
}
static void testTailVariantIdsAreDistinctFromExactRows() {
// The …_TAIL variants must be NEW forever-stable ids, not a rename of the exact
// rows (renaming would break shipped keybindings on the exact-bounds actions).
const auto& table = captureActionTable();
std::string itemNoneId, itemAutoId, trackNoneId, trackAutoId;
for (const auto& def : table) {
if (def.scope == CaptureScope::Item && def.tailMode == TailMode::None) itemNoneId = def.commandString;
if (def.scope == CaptureScope::Item && def.tailMode == TailMode::Auto) itemAutoId = def.commandString;
if (def.scope == CaptureScope::Track && def.tailMode == TailMode::None) trackNoneId = def.commandString;
if (def.scope == CaptureScope::Track && def.tailMode == TailMode::Auto) trackAutoId = def.commandString;
}
// The exact-bounds ids are the shipped CAPTURE_ITEM / CAPTURE_TRACK strings —
// pin them so a future edit that silently changes them fails the gate.
CHECK(itemNoneId == "CEREBELLUM_REASAMPLER_CAPTURE_ITEM");
CHECK(trackNoneId == "CEREBELLUM_REASAMPLER_CAPTURE_TRACK");
CHECK(itemAutoId == "CEREBELLUM_REASAMPLER_CAPTURE_ITEM_TAIL");
CHECK(trackAutoId == "CEREBELLUM_REASAMPLER_CAPTURE_TRACK_TAIL");
}
int main() {
@@ -181,6 +275,11 @@ int main() {
testSelectedItemsSingleFile();
testRazorSingleFile();
testRealtimeIsUnsupportedOffline();
testTailNoneIsExactBounds();
testTailAutoIsSurgicalTrim();
testAutoTrimRatioDerivesFromDb();
testTailManualFixedNoTrim();
testTailManualClampsToCap();
testParseSingleTrackAudioArea();
testParseMultipleAreas();
testParseSkipsEnvelopeLaneAreas();
@@ -190,7 +289,8 @@ int main() {
testRangeInference();
testItemScopeBypassesEverythingButTake();
testTrackScopeKeepsSelfBypassesAncestorsAndMaster();
testTableHasTwoScopeRows();
testTableHasScopeAndTailVariants();
testTailVariantIdsAreDistinctFromExactRows();
if (g_fail == 0) std::printf("render_settings: all tests passed\n");
else std::printf("render_settings: %d CHECK(s) FAILED\n", g_fail);