diff --git a/src/capture.cpp b/src/capture.cpp index b4ebeae..183baf6 100644 --- a/src/capture.cpp +++ b/src/capture.cpp @@ -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(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(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(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 diff --git a/src/capture.h b/src/capture.h index 4d43846..03b61ca 100644 --- a/src/capture.h +++ b/src/capture.h @@ -20,6 +20,7 @@ #include #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 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). diff --git a/src/main.cpp b/src/main.cpp index bd126fc..d30a5e3 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -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; diff --git a/src/render_settings.cpp b/src/render_settings.cpp index 7fc4072..28d0f35 100644 --- a/src/render_settings.cpp +++ b/src/render_settings.cpp @@ -3,10 +3,59 @@ #include "render_settings.h" +#include +#include #include 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& ranges) { } const std::vector& 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 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; } diff --git a/src/render_settings.h b/src/render_settings.h index 74ab139..3d948c0 100644 --- a/src/render_settings.h +++ b/src/render_settings.h @@ -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& 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& captureActionTable(); } // namespace reasampler diff --git a/tests/test_render_settings.cpp b/tests/test_render_settings.cpp index 2469e43..e7b5ef5 100644 --- a/tests/test_render_settings.cpp +++ b/tests/test_render_settings.cpp @@ -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 #include #include #include @@ -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 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);