From 2d225258b49138475de7646f4c4373df45304439 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Thu, 23 Jul 2026 17:08:24 -0400 Subject: [PATCH 1/3] 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). --- src/capture.cpp | 54 ++++++++------ src/capture.h | 12 ++- src/main.cpp | 6 +- src/render_settings.cpp | 86 +++++++++++++++++++--- src/render_settings.h | 88 ++++++++++++++++++++-- tests/test_render_settings.cpp | 130 +++++++++++++++++++++++++++++---- 6 files changed, 313 insertions(+), 63 deletions(-) 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); From 9a9339b90b50ad0f6dcfbb0fff26b21289d5b452 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Thu, 23 Jul 2026 17:32:19 -0400 Subject: [PATCH 2/3] refactor(capture): expose offline tail as docked-panel toggle 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. --- CMakeLists.txt | 18 +++++- docs/product/capture-tail.md | 22 ++++--- src/bank_panel.cpp | 103 +++++++++++++++++++++++++++++++-- src/bank_panel.h | 13 +++++ src/main.cpp | 15 ++++- src/render_settings.cpp | 39 +++++-------- src/render_settings.h | 23 ++++---- src/tail_control.cpp | 33 +++++++++++ src/tail_control.h | 49 ++++++++++++++++ tests/test_render_settings.cpp | 55 +++++++----------- tests/test_tail_control.cpp | 83 ++++++++++++++++++++++++++ 11 files changed, 363 insertions(+), 90 deletions(-) create mode 100644 src/tail_control.cpp create mode 100644 src/tail_control.h create mode 100644 tests/test_tail_control.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 6b2a298..76b76d6 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -96,6 +96,18 @@ add_library(render_settings STATIC src/render_settings.cpp) target_include_directories(render_settings PUBLIC src) target_link_libraries(render_settings PUBLIC bank_model) +# --------------------------------------------------------------------------- +# 2f') Pure tail_control library — NO REAPER, NO SWELL. The docked bank_panel's +# tail-mode toggle logic (T1 exposure): TailSetting state, cycle order +# (None->Auto->Manual->None), the manual-length clamp to the 8 s cap, and the +# toggle label text. Split out so the toggle's cycle/clamp/label is unit-tested +# outside the DAW; the bank_panel footer that draws it + routes clicks is +# DAW-verified. Depends on render_settings for the pure TailMode enum + caps. +# --------------------------------------------------------------------------- +add_library(tail_control STATIC src/tail_control.cpp) +target_include_directories(tail_control PUBLIC src) +target_link_libraries(tail_control PUBLIC render_settings) + # --------------------------------------------------------------------------- # 2g) Pure realtime_record library — NO REAPER, NO SWELL. The M8 realtime-record # logic: capture scope + FX-tap point -> I_RECMODE / I_RECMODE_FLAGS values, @@ -148,6 +160,10 @@ add_executable(render_settings_tests tests/test_render_settings.cpp) target_link_libraries(render_settings_tests PRIVATE render_settings) add_test(NAME render_settings_tests COMMAND render_settings_tests) +add_executable(tail_control_tests tests/test_tail_control.cpp) +target_link_libraries(tail_control_tests PRIVATE tail_control) +add_test(NAME tail_control_tests COMMAND tail_control_tests) + add_executable(realtime_record_tests tests/test_realtime_record.cpp) target_link_libraries(realtime_record_tests PRIVATE realtime_record) add_test(NAME realtime_record_tests COMMAND realtime_record_tests) @@ -183,7 +199,7 @@ add_library(reaper_reasampler MODULE src/track_guid.cpp src/actions.cpp ) -target_link_libraries(reaper_reasampler PRIVATE bank_model capture_paths peaks bank_grid mode_switch view_mode_model insert_plan render_settings realtime_record) +target_link_libraries(reaper_reasampler PRIVATE bank_model capture_paths peaks bank_grid mode_switch view_mode_model insert_plan render_settings tail_control realtime_record) target_include_directories(reaper_reasampler PRIVATE ${SDK_INC} ${WDL_INC}) set_target_properties(reaper_reasampler PROPERTIES PREFIX "" OUTPUT_NAME "reaper_reasampler") diff --git a/docs/product/capture-tail.md b/docs/product/capture-tail.md index fd63534..4a46843 100644 --- a/docs/product/capture-tail.md +++ b/docs/product/capture-tail.md @@ -384,14 +384,18 @@ Neither is in scope now; both are single-constant seams so promotion is cheap. trailing edge, and the tail is appended after `ENDPOS`. But a range that itself ends in near-silence before a loud transient is the edge case to check the trim doesn't over-eat.) -- **Auto as the action default?** Should the two shipped capture actions - (`CAPTURE_ITEM` / `CAPTURE_TRACK`, currently all TailMode::None - per `render_settings.cpp §captureActionTable`) flip to Auto tail by default once - this ships, or should tail be a separate action variant / a modifier? Product call - for Daniel. **Leaning:** a per-action-family toggle or a paired "…​with tail" - variant rather than silently changing the existing actions' behavior — the current - exact-bounds default is a documented contract and some captures (chops, wavetable - grabs) want no tail. Not blocking the offline implementation; the request-level - contract (three tail states) is independent of which action sets which. +- **Auto as the action default? — DECIDED (2026-07-23).** The two shipped capture + actions (`CAPTURE_ITEM` / `CAPTURE_TRACK`) stay `TailMode::None` by default; the + tail mode is exposed as a **settings toggle in the docked bank panel** (None / Auto + / Manual — the footer strip, `bank_panel.cpp` + pure `tail_control`), and the plain + capture actions READ that toggle when building the `CaptureRequest`. 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 runs `TailMode::None` regardless 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. diff --git a/src/bank_panel.cpp b/src/bank_panel.cpp index 5e09aa9..1b3a9e0 100644 --- a/src/bank_panel.cpp +++ b/src/bank_panel.cpp @@ -42,6 +42,7 @@ #include "mode_switch.h" #include "peaks.h" #include "persist.h" +#include "tail_control.h" // TailSetting, cycleTailMode, tailToggleLabel (pure) #include "view.h" // applyMode — the D2/D4 mode-activation entrypoint the switch fires // SWELL / LICE. On macOS/Linux SWELL is provided by the host (SWELL_PROVIDED_BY_APP); @@ -134,6 +135,20 @@ const LICE_pixel kColSegBorder = LICE_RGBA(70, 70, 76, 255); // segment di const COLORREF kRgbSegText = RGB(170, 170, 176); // inactive label const COLORREF kRgbSegActiveText = RGB(220, 235, 228); // active label +// --- Tail-mode footer (T1 exposure) ------------------------------------------- +// A fixed-height strip at the BOTTOM of the client area holding the tail-mode +// toggle ("Tail: Off / Auto / Manual"). Clicking anywhere in it cycles the mode +// (None -> Auto -> Manual -> None). Display/settings only: it mutates the panel's +// in-memory tail setting the plain capture actions read — NEVER the project/bank/ +// arrange. The cycle/label logic is the pure tail_control module; only the draw + +// click routing is here. The grid viewport is shortened by this strip's height so +// cells never draw under it. +constexpr int kFooterHeight = 26; // px; fixed strip at the bottom + +const LICE_pixel kColFooterBg = LICE_RGBA(20, 20, 22, 255); // footer strip fill +const LICE_pixel kColFooterBorder = LICE_RGBA(70, 70, 76, 255); // top divider +const COLORREF kRgbFooterText = RGB(190, 205, 198); // toggle label + // --- Panel state -------------------------------------------------------------- // A computed thumbnail: the per-channel envelope at a known width. Held in the @@ -173,6 +188,12 @@ struct PanelState { // clear the selection rather than risk indices pointing past the new count. int selItemCount = 0; + // --- Tail-mode toggle (T1 exposure) --------------------------------------- + // The current tail setting the plain capture actions read (bankPanelTailSetting). + // Default None (exact bounds). In-memory only — resets on panel teardown; project + // persistence is a noted follow-on. Mutated ONLY by a click in the footer strip. + TailSetting tail; + // --- Audition preview (Wave B) -------------------------------------------- // // The stock preview register we hand to PlayPreview/StopPreview. Its cs/mutex @@ -424,6 +445,45 @@ void drawModeSwitch(LICE_IBitmap* bmp, int w) { } } +// The tail-toggle footer rect for a client of width `w` and height `h`: the +// full-width strip of fixed height pinned to the BOTTOM. A RECT (not HeaderRect) +// since the whole strip is one hit target — a click anywhere in it cycles the mode. +// Shared by paint and click routing so both agree on the band. Degenerate (empty) +// when the client is too short to host it above the header. +RECT panelFooter(int w, int h) { + RECT rc{}; + rc.left = 0; + rc.right = w; + rc.top = h - kFooterHeight; + rc.bottom = h; + // Clamp so the footer never rides up into (or above) the header band on a very + // short panel — it collapses to empty rather than overlapping the mode switch. + if (rc.top < kHeaderHeight) rc.top = rc.bottom; // empty: top == bottom + return rc; +} + +// Draws the tail-mode toggle into the footer strip: a filled band, a top divider, +// and the current mode's label ("Tail: Off / Auto / Manual") from the pure +// tail_control module. READ-ONLY: reads g_panel.tail; the click handler mutates it. +void drawTailFooter(LICE_IBitmap* bmp, int w, int h) { + const RECT f = panelFooter(w, h); + if (f.top >= f.bottom) return; // no room — skip (short panel) + + LICE_FillRect(bmp, f.left, f.top, w, kFooterHeight, kColFooterBg, 1.0f, 0); + // Top divider so the strip reads as distinct from the grid above it. + LICE_Line(bmp, f.left, f.top, f.right, f.top, kColFooterBorder, 1.0f, 0, false); + + HDC dc = bmp->getDC(); + if (!dc) return; + const std::string label = tailToggleLabel(g_panel.tail); + RECT rc = f; + rc.left += 8; // small left pad so the label is not flush against the edge + SetTextColor(dc, kRgbFooterText); + SetBkMode(dc, TRANSPARENT); + DrawText(dc, label.c_str(), -1, &rc, + DT_LEFT | DT_VCENTER | DT_SINGLELINE | DT_END_ELLIPSIS); +} + // The cell rects for the panel's CURRENT client width and bank size, translated // DOWN by the header height so the grid sits below the mode switch. Both paint and // mouse hit-testing call this so they share identical geometry (no drift between @@ -469,11 +529,15 @@ void paintPanel(HWND hwnd, HDC hdc) { // Draw each cell's thumbnail. Inner drawable width == cell width - inset; // compute the envelope at the cell's inner column count so bins map 1:1. const int binWidth = kGrid.cellWidth - 4; + // Cells must not draw under the footer strip: the visible grid stops at the + // footer top (or the client bottom when the panel is too short for a footer). + const RECT footer = panelFooter(w, h); + const int gridBottom = footer.top < footer.bottom ? footer.top : h; for (std::size_t i = 0; i < rects.size(); ++i) { const CellRect& rect = rects[i]; - // Skip cells entirely below the viewport (Wave A has no scroll; this - // just avoids computing thumbnails that cannot be seen). - if (rect.y >= h) continue; + // Skip cells entirely below the visible grid area (Wave A has no scroll; + // this just avoids computing thumbnails that cannot be seen). + if (rect.y >= gridBottom) continue; const int idx = static_cast(i); const bool selected = g_panel.selection.contains(idx); const bool focused = g_panel.selection.focus == idx; @@ -482,9 +546,10 @@ void paintPanel(HWND hwnd, HDC hdc) { } } - // The mode switch draws LAST so its header band overlays the top of the grid / - // empty-state area regardless of which branch ran above. + // The mode switch and tail footer draw LAST so their bands overlay the top/bottom + // of the grid / empty-state area regardless of which branch ran above. drawModeSwitch(&bmp, w); + drawTailFooter(&bmp, w, h); BitBlt(hdc, 0, 0, w, h, bmp.getDC(), 0, 0, SRCCOPY); } @@ -675,6 +740,23 @@ void handleClick(int x, int y) { } } + // Tail-mode footer: a click anywhere in the bottom strip cycles the tail mode + // (None -> Auto -> Manual -> None) and repaints. Settings-only — it mutates the + // panel's in-memory tail setting the capture actions read, and NOTHING in the + // project/bank/arrange. Checked before the grid so a footer click never selects. + { + RECT cr{}; + GetClientRect(g_panel.hwnd, &cr); + const int w = cr.right - cr.left; + const int h = cr.bottom - cr.top; + const RECT f = panelFooter(w, h); + if (f.top < f.bottom && x >= f.left && x < f.right && y >= f.top && y < f.bottom) { + g_panel.tail.mode = cycleTailMode(g_panel.tail.mode); + invalidatePanel(); + return; // footer click consumed; do NOT fall through to grid selection + } + } + const std::vector rects = panelRects(); const int hit = hitTestCell(x, y, rects); const int count = bankItemCount(); @@ -906,6 +988,17 @@ void bankPanelRefresh() { InvalidateRect(g_panel.hwnd, nullptr, FALSE); } +TailSetting bankPanelTailSetting() { + // In-memory for the extension's lifetime (g_panel is static): the toggle's mode + // survives panel open/close and bank changes, and resets to the default None only + // on extension unload. Persistence across project reload is a noted follow-on. + // manualMs is clamped here so a caller always receives a within-cap length, even + // if a future fine-adjust UI stored an over-cap value. + TailSetting s = g_panel.tail; + s.manualMs = clampManualMs(s.manualMs); + return s; +} + void bankPanelShutdown() { closePanel(); // stops audition + destroys the window deinitPreview(); // destroy the preview lock (after the last stop) diff --git a/src/bank_panel.h b/src/bank_panel.h index 782658f..6e38528 100644 --- a/src/bank_panel.h +++ b/src/bank_panel.h @@ -13,6 +13,8 @@ #include #include +#include "tail_control.h" // TailSetting — the panel's tail-mode toggle state + namespace reasampler { class ReaSamplerSession; @@ -48,6 +50,17 @@ std::vector bankPanelSelectedSampleIds(); // reflected without the panel diffing the bank itself. void bankPanelRefresh(); +// The panel's current tail-mode setting (mode + Manual length), read by the plain +// CAPTURE_ITEM / CAPTURE_TRACK actions when building a CaptureRequest so a capture +// applies whatever the panel toggle is set to. Default None (exact bounds) — a +// capture with no explicit choice stays byte-identical to today. Extension-session +// setting: persists across project loads and panel open/close within a REAPER session; +// resets to None only when the extension unloads (fresh REAPER session). Project +// persistence across REAPER restarts is a noted follow-on. +// Safe to call before the panel has ever opened (returns the default). READ of panel +// state only; the toggle is mutated by a click inside the panel, never here. +TailSetting bankPanelTailSetting(); + // Tears the panel down on extension unload: destroys the window and releases any // cached thumbnails / PCM handles. Mirror of bankPanelInit; safe if never opened. void bankPanelShutdown(); diff --git a/src/main.cpp b/src/main.cpp index d30a5e3..200a11a 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -69,12 +69,17 @@ static std::vector g_captureAccels; // * CAPTURE_MASTER and CAPTURE_MASTER_REALTIME — the master offline scope and the // master realtime action are REMOVED (capture is now item + track only; realtime // taps the selected track). Their shipped ids are retired so old keybindings clear. +// * CAPTURE_ITEM_TAIL and CAPTURE_TRACK_TAIL — the former per-action tail variants +// are REMOVED; tail is now a panel-setting toggle, not a paired action. Retired so +// old keybindings clear. static const char* const kRetiredCaptureCmdStrings[] = { "CEREBELLUM_REASAMPLER_CAPTURE_TRACKS_WET", "CEREBELLUM_REASAMPLER_CAPTURE_ITEMS_WET", "CEREBELLUM_REASAMPLER_CAPTURE_RAZOR_WET", "CEREBELLUM_REASAMPLER_CAPTURE_MASTER", "CEREBELLUM_REASAMPLER_CAPTURE_MASTER_REALTIME", + "CEREBELLUM_REASAMPLER_CAPTURE_ITEM_TAIL", + "CEREBELLUM_REASAMPLER_CAPTURE_TRACK_TAIL", }; // Command id for "ReaSampler: toggle bank panel" (M5). FOREVER-STABLE string. @@ -505,13 +510,19 @@ static void RunCapture(const reasampler::CaptureActionDef& def) return; } + // The tail mode is a PANEL SETTING (docked bank panel's toggle), not a per-action + // variant: the plain capture actions apply whatever the panel is set to. Default + // is None (exact bounds / byte-identical to today) until the user opts in via the + // toggle. tailMs is meaningful only for Manual and is pre-clamped by the panel. + const reasampler::TailSetting tail = reasampler::bankPanelTailSetting(); + reasampler::CaptureRequest req; req.sourceMode = reasampler::sourceModeForScope(def.scope); 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.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.tailMode = tail.mode; // None / Auto / Manual, from the panel toggle + req.tailMs = tail.manualMs; // Manual-only (clamped); ignored for None/Auto req.sampleRate = 0; // follow project rate req.channelCount = 2; req.bitDepth = reasampler::WavBitDepth::Float32; // deterministic, no dither diff --git a/src/render_settings.cpp b/src/render_settings.cpp index 28d0f35..3f4e3ac 100644 --- a/src/render_settings.cpp +++ b/src/render_settings.cpp @@ -179,38 +179,25 @@ RazorRange razorUnionBounds(const std::vector& ranges) { } const std::vector& captureActionTable() { - // 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. + // Built once (function-local static): two SCOPE actions, item + track. Both + // exact bounds by default; the tail mode a capture applies is read from the + // docked-panel setting at fire time (tail_control + bank_panel), so tail is NOT + // a per-action variant. 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, exact bounds — item/take FX only. + // Item scope — item/take FX only. {"CEREBELLUM_REASAMPLER_CAPTURE_ITEM", "ReaSampler: capture selected item(s)", "item", - CaptureScope::Item, TailMode::None}, + CaptureScope::Item}, - // Track scope, exact bounds — item FX + the track's own FX. + // Track scope — item FX + the track's own FX. {"CEREBELLUM_REASAMPLER_CAPTURE_TRACK", "ReaSampler: capture selected track(s)", "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}, + CaptureScope::Track}, }; return table; } diff --git a/src/render_settings.h b/src/render_settings.h index 3d948c0..e331fe9 100644 --- a/src/render_settings.h +++ b/src/render_settings.h @@ -89,7 +89,7 @@ double autoTrimEndRatio(); // 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. +// normalize). The user-facing tail-on option (panel toggle). // Manual — a fixed tail length (clamped to the 8 s cap), no trim. enum class TailMode { None, @@ -213,13 +213,11 @@ 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), 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). +// One row per bindable SCOPE action: item and track. The range each captures +// (razor-else-time) is inferred at fire time, not a mode. TAIL is NOT a per-action +// variant — the tail MODE (None/Auto/Manual) is a panel SETTING the capture reads +// at fire time (see tail_control + bank_panel), so a single pair of actions covers +// every tail state. 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). @@ -228,17 +226,16 @@ 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. // -// 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. +// Two 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. The tail mode each +// capture applies is read from the docked-panel setting, not baked into the row. const std::vector& captureActionTable(); } // namespace reasampler diff --git a/src/tail_control.cpp b/src/tail_control.cpp new file mode 100644 index 0000000..3fc2cbc --- /dev/null +++ b/src/tail_control.cpp @@ -0,0 +1,33 @@ +// tail_control — pure implementation. See tail_control.h. NO REAPER / SWELL / vendor. + +#include "tail_control.h" + +#include + +namespace reasampler { + +TailMode cycleTailMode(TailMode current) { + switch (current) { + case TailMode::None: return TailMode::Auto; + case TailMode::Auto: return TailMode::Manual; + case TailMode::Manual: return TailMode::None; + } + return TailMode::None; // unreachable for a valid enum; fail to the safe default +} + +double clampManualMs(double manualMs) { + // Same runaway guard the pure tailRenderSettingsFor applies to Manual: floor a + // negative request to 0, cap at the 8 s ceiling. + return std::clamp(manualMs, 0.0, kMaxTailMs); +} + +std::string tailToggleLabel(const TailSetting& setting) { + switch (setting.mode) { + case TailMode::None: return "Tail: Off"; + case TailMode::Auto: return "Tail: Auto"; + case TailMode::Manual: return "Tail: Manual"; + } + return "Tail: Off"; // unreachable for a valid enum; fail to the safe default +} + +} // namespace reasampler diff --git a/src/tail_control.h b/src/tail_control.h new file mode 100644 index 0000000..ba6cf2d --- /dev/null +++ b/src/tail_control.h @@ -0,0 +1,49 @@ +#pragma once +// tail_control — the REAPER-free logic behind the docked bank_panel's tail-mode +// toggle. The panel shell (bank_panel.cpp) owns the SWELL window, LICE drawing, and +// click hit-testing; what is NOT DAW-bound — the cycle order, the manual-length +// clamp, and the toggle's label text — lives here so it is unit-tested outside the +// DAW (CLAUDE.md §load-bearing split). Mirror of bank_grid / mode_switch. +// +// PURE MODULE: NO REAPER types, NO SWELL, NO vendor/ includes. Standard library +// only (plus render_settings for the pure TailMode enum). Builds and unit-tests +// without REAPER. + +#include + +#include "render_settings.h" // TailMode (pure enum) — the three-state tail contract + +namespace reasampler { + +// The Manual-mode starting length. 2 s is a musically useful default tail (a bar of +// reverb throw at a moderate tempo) that is well under the 8 s cap. A fine-adjust UI +// (+/- click zones or scroll) is a noted follow-on; this pass ships a fixed default. +inline constexpr double kDefaultManualTailMs = 2000.0; + +// The panel's current tail setting: the mode plus the length used ONLY when the +// mode is Manual. Held as in-memory panel/session state (bank_panel.cpp), default +// None so a capture with no explicit choice stays exact-bounds / byte-identical to +// today. `manualMs` is a stored default a future fine-adjust UI can tune; it is +// clamped to the 8 s cap (kMaxTailMs) before it ever reaches a CaptureRequest. +struct TailSetting { + TailMode mode = TailMode::None; + double manualMs = kDefaultManualTailMs; +}; + +// Cycles the tail mode: None -> Auto -> Manual -> None. Pure so the wrap order is +// pinned by a test and the panel's click handler owns no enum arithmetic of its own. +// An out-of-range value (unreachable for a valid enum) cycles back to None. +TailMode cycleTailMode(TailMode current); + +// The effective manual length a Manual capture uses: `manualMs` clamped to +// [0, kMaxTailMs] (the runaway guard the pure tailRenderSettingsFor also applies). +// Exposed so the panel can show the clamped value and main.cpp hands a pre-clamped +// tailMs into the CaptureRequest. Meaningful only for TailMode::Manual. +double clampManualMs(double manualMs); + +// The toggle's label for a setting, e.g. "Tail: Off", "Tail: Auto", "Tail: Manual". +// (Manual omits the length here — the panel is unobtrusive; a length readout can be +// added with the fine-adjust follow-on.) Pure so the exact strings are test-pinned. +std::string tailToggleLabel(const TailSetting& setting); + +} // namespace reasampler diff --git a/tests/test_render_settings.cpp b/tests/test_render_settings.cpp index e7b5ef5..a1727e7 100644 --- a/tests/test_render_settings.cpp +++ b/tests/test_render_settings.cpp @@ -216,17 +216,16 @@ static void testTrackScopeKeepsSelfBypassesAncestorsAndMaster() { CHECK(p.bypassMaster); // no master FX } -// --- captureActionTable: the scope x tail-variant taxonomy ------------------- +// --- captureActionTable: the scope taxonomy ---------------------------------- -static void testTableHasScopeAndTailVariants() { +static void testTableHasBothScopes() { const auto& table = captureActionTable(); - // Four rows: item/track x (None exact-bounds, Auto "…with tail"). No master scope. - CHECK(table.size() == 4); + // Two rows: item + track. No master scope, and NO tail variants — tail is a + // panel setting the capture reads at fire time, not a per-action row. + CHECK(table.size() == 2); std::set ids; - // 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; + int item = 0, track = 0; for (const auto& def : table) { // Every id is a non-empty CEREBELLUM_REASAMPLER_ string and is UNIQUE // (duplicate ids would collide on registration). @@ -235,38 +234,26 @@ static void testTableHasScopeAndTailVariants() { 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 && 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; + if (def.scope == CaptureScope::Item) ++item; + if (def.scope == CaptureScope::Track) ++track; } - // 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); + // Exactly one row per scope — no dupes, no gaps, no tail variants. + CHECK(item == 1); + CHECK(track == 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). +static void testScopeActionIdsAreTheShippedStrings() { + // Pin the shipped CAPTURE_ITEM / CAPTURE_TRACK ids so a future edit that silently + // changes them (breaking user keybindings) fails the gate. const auto& table = captureActionTable(); - std::string itemNoneId, itemAutoId, trackNoneId, trackAutoId; + std::string itemId, trackId; 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; + if (def.scope == CaptureScope::Item) itemId = def.commandString; + if (def.scope == CaptureScope::Track) trackId = 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"); + CHECK(itemId == "CEREBELLUM_REASAMPLER_CAPTURE_ITEM"); + CHECK(trackId == "CEREBELLUM_REASAMPLER_CAPTURE_TRACK"); } int main() { @@ -289,8 +276,8 @@ int main() { testRangeInference(); testItemScopeBypassesEverythingButTake(); testTrackScopeKeepsSelfBypassesAncestorsAndMaster(); - testTableHasScopeAndTailVariants(); - testTailVariantIdsAreDistinctFromExactRows(); + testTableHasBothScopes(); + testScopeActionIdsAreTheShippedStrings(); if (g_fail == 0) std::printf("render_settings: all tests passed\n"); else std::printf("render_settings: %d CHECK(s) FAILED\n", g_fail); diff --git a/tests/test_tail_control.cpp b/tests/test_tail_control.cpp new file mode 100644 index 0000000..632e8c7 --- /dev/null +++ b/tests/test_tail_control.cpp @@ -0,0 +1,83 @@ +// Standalone tests for reasampler::tail_control — no REAPER, no framework. Covers +// the pure pieces behind the docked panel's tail-mode toggle: the cycle order +// (None -> Auto -> Manual -> None), the manual-length clamp to the 8 s cap, and the +// toggle label text. The drawing / click hit-testing is DAW-verified in bank_panel. + +#include "../src/tail_control.h" + +#include +#include + +using namespace reasampler; + +static int g_fail = 0; +#define CHECK(cond) do { if(!(cond)) { \ + std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) + +// --- cycleTailMode: the toggle order ------------------------------------------ + +static void testCycleOrderIsNoneAutoManualNone() { + // None -> Auto -> Manual -> None, wrapping. The click handler relies on exactly + // this order; a reorder (e.g. skipping Manual) would fail here. + CHECK(cycleTailMode(TailMode::None) == TailMode::Auto); + CHECK(cycleTailMode(TailMode::Auto) == TailMode::Manual); + CHECK(cycleTailMode(TailMode::Manual) == TailMode::None); +} + +static void testCycleThreeStepsReturnsToStart() { + // Three cycles from any state land back on that state (a full lap of the 3-cycle). + TailMode m = TailMode::None; + m = cycleTailMode(m); + m = cycleTailMode(m); + m = cycleTailMode(m); + CHECK(m == TailMode::None); +} + +// --- clampManualMs: the runaway guard ----------------------------------------- + +static void testManualClampInRangeIsUnchanged() { + // A value inside [0, kMaxTailMs] passes through untouched. + CHECK(clampManualMs(kDefaultManualTailMs) == kDefaultManualTailMs); + CHECK(clampManualMs(0.0) == 0.0); + CHECK(clampManualMs(kMaxTailMs) == kMaxTailMs); +} + +static void testManualClampCapsAtEightSeconds() { + // Over the 8 s cap clamps to kMaxTailMs; negative floors to 0. This mirrors the + // clamp in tailRenderSettingsFor, so the panel and the render agree on the bound. + CHECK(clampManualMs(kMaxTailMs + 5000.0) == kMaxTailMs); + CHECK(clampManualMs(-100.0) == 0.0); +} + +// --- tailToggleLabel: the exact strings the panel draws ----------------------- + +static void testLabelStringsPerMode() { + TailSetting off; off.mode = TailMode::None; + TailSetting autoM; autoM.mode = TailMode::Auto; + TailSetting man; man.mode = TailMode::Manual; + CHECK(tailToggleLabel(off) == "Tail: Off"); + CHECK(tailToggleLabel(autoM) == "Tail: Auto"); + CHECK(tailToggleLabel(man) == "Tail: Manual"); +} + +static void testDefaultSettingIsOff() { + // The zero-value setting is None (Off) with the 2 s manual default — the safe + // default the panel starts in so captures stay exact-bounds until opt-in. + TailSetting s; + CHECK(s.mode == TailMode::None); + CHECK(s.manualMs == kDefaultManualTailMs); + CHECK(tailToggleLabel(s) == "Tail: Off"); +} + +int main() { + testCycleOrderIsNoneAutoManualNone(); + testCycleThreeStepsReturnsToStart(); + testManualClampInRangeIsUnchanged(); + testManualClampCapsAtEightSeconds(); + testLabelStringsPerMode(); + testDefaultSettingIsOff(); + + if (g_fail == 0) std::printf("tail_control: all tests passed\n"); + else std::printf("tail_control: %d CHECK(s) FAILED\n", g_fail); + return g_fail == 0 ? 0 : 1; +} From 81fa37fe9416f49241d04d5676c5a8dacf9b5a76 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Thu, 23 Jul 2026 17:52:03 -0400 Subject: [PATCH 3/3] Archive Milestone 8 and Milestone T1 in docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move completed M8 (async realtime backend — landed as selected-track post-fader tap, not master) and T1 (offline capture tail + panel toggle) from PLAN.md to COMPLETED.md; resolved open questions removed. T2 realtime tail remains. --- COMPLETED.md | 126 +++++++++++++++++++++++++++++++++++++++++++++++++++ PLAN.md | 53 ---------------------- 2 files changed, 126 insertions(+), 53 deletions(-) diff --git a/COMPLETED.md b/COMPLETED.md index d92030f..c4a3b22 100644 --- a/COMPLETED.md +++ b/COMPLETED.md @@ -307,3 +307,129 @@ auto-inserts into the arrange. - **FX-scope semantics (initial rework):** item = item/take FX only; track = item FX + the selected track's own track FX; master = full chain. For item/track, the out-of-scope chain (ancestors + master, plus the item's own track for item scope) is neutralized during the render. - **Master scope subsequently removed:** capture is now **two scopes — item and track only**. `CAPTURE_MASTER` and `CAPTURE_MASTER_REALTIME` are retired (to capture the master, render a track instead). The master track is still neutralized as out-of-scope chain for both item and track captures; it is a bypass target, not a capture scope. The realtime backend taps the selected track (track scope only; item realtime deferred). - **`FxBypassGuard` (RAII):** snapshot → neutralize (FX bypassed via `I_FXEN`; gain zeroed via `D_VOL`; pan/width/pan-law/mode set to unity via `D_PAN`/`D_WIDTH`/`D_PANLAW`/`I_PANMODE`) → render → restore. Non-destructive. This guard is the reusable mechanism M8 (realtime backend) and M10 (null-test / true dry) build on. + +--- + +## Milestone 8 — RealtimeRecordBackend +**Goal:** Realtime record behind the same `ICaptureBackend`, producing identical +bank entries. CONTEXT.md §capture (realtime), §Precision invariants. +**Verify (in DAW):** Hidden temp track taps each selected track's own post-fader +output via a `CreateTrackSend`; recorded file moves into the bank; **non-destructive** +— temp track (and its sends) removed cleanly, every snapshotted track arm, time +selection, and edit cursor restored unchanged on every terminal path. + +- [x] Track-scope tap: a `CreateTrackSend(source, temp)` from each selected track + into a hidden temp track (`B_MAINSEND=0`, hidden from TCP/mixer). The temp records + its own post-fader output — capturing each source track's output **after its own FX + and fader, before the parent/folder/master sums it** — chain-independent by + construction. No `FxBypassGuard` needed or used. Multiple selected tracks sum in the + temp track (matching offline track scope). Item realtime deferred (`UnsupportedMode`). + No track selected → refused. +- [x] Timer-driven async state machine (`begin`/`tick`/`abort` driven by `OnTimer`, + non-blocking — REAPER's UI stays responsive across the record). `begin()` validates, + snapshots all state, creates the temp track, routes the tap, arms, calls + `CSurf_OnRecord`, and **returns immediately**. `tick()` (called from `OnTimer`) + reads the transport via `GetPlayStateEx`/`GetPlayPositionEx` scoped to the record's + own `ReaProject*` (project-switch safe), advances the pure `advanceRecordPhase` + state machine, and on a terminal verdict stops + finalizes/restores. `abort()` is + the force-terminate path for shutdown and project switch. +- [x] `RealtimeCaptureState` snapshot + idempotent restore: snapshots cursor, + time selection, and every other track's `I_RECARM`; restore() is latched + (`restored_` flag) and safe to call from whichever terminal path fires first. + Terminal paths: normal completion, manual stop, error, second-capture reject, + project switch (project-scoped `OnStopButtonEx(proj_)`, never the global + `CSurf_OnStop`), **project close** (guarded by `ValidatePtr2(nullptr, proj_, + "ReaProject*")` — a closed project calls `dropWithoutRestore()` rather than + touching freed pointers), and extension unload. +- [x] `Finalizing` flush-wait before file move: after the transport stops, + `tick()` waits for the recorded file size to be positive and stable across a tick + before calling `finalizeRecording` (file is no longer being written by REAPER's + audio thread). A wall-clock ceiling (steady-clock, independent of the play cursor) + bounds both the total record duration and the flush wait separately. +- [x] Move recorded source into bank: `recordedFilePath` discovers the take's source + file from the temp track's first media item; `finalizeRecording` moves it into the + bank folder (cross-volume fallback: copy+remove); populates a `Sample` via + `sampleFromRecordedCapture`; clean teardown via `restore()` deletes the temp track + (which REAPER uses to automatically remove every send routed into it). +- [x] Dialog-free; realtime is inherently non-deterministic (documented, not asserted + bit-identical); saved-project gate (refuses + prompts Save-As if unsaved, matching + offline). Bindable **cancel** action registered. Master scope removed entirely — + to capture the master, render a track. + +**Notes/decisions:** +- **Track scope only this increment.** Item realtime is deferred: item scope needs + per-item take isolation on top of the track-output tap — a separate increment. +- **TAP vs. FxBypassGuard.** The `CreateTrackSend` defaults to post-fader + (`I_SENDMODE=0`) with full-stereo (`I_SRCCHAN` default): post-fader taps the source + track after its own FX and fader/pan, before the parent sums it. The parent chain + downstream of that branch is not in the tapped path at all — so there is nothing to + neutralize and `FxBypassGuard` (which mutates the live chain, altering the user's + monitoring) is deliberately not used. This also fixed the earlier silent-file bug + from the spike, which sent FROM the master INTO a temp track (a feedback loop REAPER + refuses, recording silence). A regular track→track send has no feedback. +- **Project-close guard.** `abort()` gates every REAPER call on + `ValidatePtr2(nullptr, proj_, "ReaProject*")`. A closed project already reclaimed + its temp track, arms, and transport — `dropWithoutRestore()` latches `restored_` + and clears `temp_` / `armSnaps_` without touching any REAPER pointer. +- **No undo block.** The transient mutations (temp track, sends, arm, transport) are + fully reversed by `restore()`; surfacing them as an undo point would pollute the + user's history with an internal scaffold they cannot meaningfully undo. + +--- + +## T1 — offline tail: auto (default) + manual override +**Goal:** Preserve decay tails on offline captures. **Auto**: render an 8 s-capped +tail, then auto-trim trailing silence to -72 dB via a **surgical** `RENDER_NORMALIZE` +(only the trim-end bit, `32768`) + a derived `RENDER_TRIMEND` amplitude ratio. +**Manual**: a fixed tail length clamped to the 8 s cap, no trim. **None** (default): +exact bounds, byte-identical to the pre-tail capture. See +`docs/product/capture-tail.md` §The offline path. +**Verify (in DAW):** A range ending mid-reverb + Auto tail ends at the -72 dB decay +point (not a hard 8 s, not the range end); a non-decaying signal caps at range + 8 s; +**two identical Auto requests are byte-identical** (deterministic trim); a +TailMode::None capture is byte-identical to the pre-tail exact-bounds capture; +Manual(N ms) yields range + N ms untrimmed, with N clamped to 8000; +`ScopedRenderSettings` restores `RENDER_NORMALIZE` and every touched setting on every +path. + +- [x] Pure layer (`render_settings.{h,cpp}`): named constants `kAutoTrimThresholdDb` + (-72) + derived `RENDER_TRIMEND` amplitude ratio via `autoTrimEndRatio()` (≈ + 0.00025119 for -72 dB, computed as `10^(dB/20)` — `std::pow` is not `constexpr` + before C++26 so this is a function, not a constant), `kMaxTailSeconds`/`kMaxTailMs` + (8 s); `TailMode { None, Auto, Manual }` enum; `TailRenderSettings` struct + (tailFlag/tailMs/normalize/trimEnd); `tailRenderSettingsFor(mode, manualTailMs)` + mapping (None = kTailFlagNone + kNormalizeDisableAll; Auto = kTailFlagCustomBounds + + kMaxTailMs + kNormalizeTrimEnd (32768) + autoTrimEndRatio(); Manual = + kTailFlagCustomBounds + clamped ms + kNormalizeDisableAll); unit-tested. +- [x] Wire the mapping into `OfflineRenderBackend` (`capture.cpp`): drives tail + + surgical-normalize (Auto) / disable-all (Manual/None) via `GetSetProjectInfo`; + `ScopedRenderSettings` snapshots and restores `RENDER_TRIMEND` alongside the + existing `RENDER_*` set. `RENDER_TAILFLAG = kTailFlagCustomBounds` (1) for Auto + and Manual — custom bounds is the always-applicable tail bit for offline captures. +- [x] `CaptureRequest` three-state tail contract (None/Auto/Manual(ms)); default + None (exact bounds, null-test-safe). The earlier `renderTail` bool/`tailMs` pair + was superseded. +- [x] Exposure: a **docked-panel footer toggle** (label "Tail: Off" / "Tail: Auto" / + "Tail: Manual", cycles on click via `cycleTailMode`) in `bank_panel.cpp`, backed + by the pure `tail_control` module (`TailSetting`, `cycleTailMode`, + `clampManualMs`, `tailToggleLabel` — unit-tested). Default `TailMode::None`. + `CAPTURE_ITEM` and `CAPTURE_TRACK` read the panel setting at fire time — **no + per-action tail variants shipped** (the "…with tail" variants were dropped in + favour of the toggle; null-test/verify captures use None explicitly). +- [x] DAW-confirm: `RENDER_TRIMEND` amplitude curve (0.00025119 ≈ -72 dB); trim-end-only + normalize (32768) does not engage fades/normalize/pad; trim never eats pre-`ENDPOS` + body. (See spec §Open questions / DAW-confirm.) + +**Notes/decisions:** +- **Surgical normalize.** `kNormalizeTrimEnd = 32768` sets only the trim-ending-silence + bit; every other postprocessing bit is clear. A fixed-threshold trailing-silence trim + scales and fades nothing, so two identical Auto requests trim at the identical sample + → bit-identical repeats hold (spec §surgical normalize). +- **`kNormalizeDisableAll = (4 << 16) = 262144`.** Used for None and Manual — the + same disable-all value the pre-tail exact-bounds capture used. +- **`tail_control` pure module** (`src/tail_control.{h,cpp}`): REAPER-free logic for + the panel toggle. `kDefaultManualTailMs = 2000.0` (2 s). Fine-adjust UI (± + click zones / scroll) is a noted follow-on; this pass ships a fixed default. +- **Follow-ons noted, not done:** Manual fine-adjust UI; per-project persistence of + the toggle (currently extension-session lifetime, resets to None on unload); T2 + realtime tail. diff --git a/PLAN.md b/PLAN.md index fb4682d..f637adc 100644 --- a/PLAN.md +++ b/PLAN.md @@ -14,21 +14,6 @@ it here and appends it to `COMPLETED.md`. --- -## Milestone 8 — RealtimeRecordBackend -**Goal:** Realtime record behind the same `ICaptureBackend`, producing identical -bank entries. CONTEXT.md §capture (realtime), §Precision invariants. -**Verify (in DAW):** Hidden temp track resamples wet output; recorded file moves -into the bank; **non-destructive** — temp track removed cleanly, source routing -and user monitoring restored unchanged. - -**Note (from M3):** The realtime backend captures during playback and does NOT invoke the offline-render path, so it is inherently dialog-free (no render-progress window) — a secondary benefit beyond hardware/performed-FX capture. - -- [ ] Hidden-track resample recipe (`I_RECMODE`/`I_RECINPUT`/`I_RECARM`, - `CSurf_OnRecord`/`CSurf_OnStop`); verify record-mode values against SDK. -- [ ] Resolve wet-master routing that does not alter user monitoring (open - question). -- [ ] Move recorded source into bank; populate identical `Sample`; clean teardown. - ## Milestone 9 — slots (MPC-style) **Goal:** "Capture to slot N" / "insert slot N", MIDI-bindable. CONTEXT.md Build order 9. @@ -72,8 +57,6 @@ invariants; drag-out places a valid file in the OS target. Carried from CONTEXT.md §Open questions — keep visible until each is closed by a landed milestone. -- **Realtime wet-master routing** that captures master output without altering the - user's monitoring. (blocks M8) - **`parseInt` narrowing hardening:** `src/bank_model.cpp` `parseInt` casts `int64_t → int` via `static_cast` without a range check; integers that fit in int64 but exceed `INT_MAX` are implementation-defined. Hardening candidate @@ -94,34 +77,6 @@ landed milestone. > threshold **-72 dB**, max-tail cap **8 s**. When a point lands, doc-keeper moves > it to `COMPLETED.md`. -## T1 — offline tail: auto (default) + manual override -**Goal:** Preserve decay tails on offline captures. **Auto** (default): render an -8 s-capped tail, then auto-trim trailing silence to -72 dB via a **surgical** -`RENDER_NORMALIZE` (only the trim-end bit set) + `RENDER_TRIMEND`. **Manual**: a -fixed tail length (clamped to the 8 s cap), no trim, keeping today's disable-all -normalize. Tail is opt-in; **None** stays byte-identical to today. See -`docs/product/capture-tail.md` §The offline path. -**Verify (in DAW):** A range ending mid-reverb + Auto tail ends at the -72 dB decay -point (not a hard 8 s, not the range end); a non-decaying signal caps at range + 8 s; -**two identical Auto requests are byte-identical** (deterministic trim); a -TailMode::None capture is byte-identical to the pre-tail exact-bounds capture; -Manual(N ms) yields range + N ms untrimmed, with N clamped to 8000; `ScopedRenderSettings` -restores `RENDER_NORMALIZE` and every touched setting on every path. - -- [ ] Pure layer (`render_settings.{h,cpp}`): named constants `kAutoTrimThresholdDb` - (-72) + derived `RENDER_TRIMEND` ratio (≈0.00025119), `kMaxTailSeconds`/`kMaxTailMs` - (8 s); a `TailMode { None, Auto, Manual }` → (`RENDER_TAILFLAG`/`RENDER_TAILMS`/ - `RENDER_NORMALIZE`/`RENDER_TRIMEND`) mapping + the manual-tail clamp; unit-tested. -- [ ] Wire the mapping into `OfflineRenderBackend` (`capture.cpp`): drive the tail + - surgical-normalize (Auto) / disable-all (Manual/None) values; snapshot/restore - `RENDER_TRIMEND` alongside the existing `RENDER_*` set. `RENDER_TAILFLAG = 1` - unconditionally (custom bounds — not per range type). -- [ ] Replace/extend `CaptureRequest.renderTail`(bool)/`tailMs` with the three-state - tail contract (None/Auto/Manual(ms)); default None (exact bounds, null-test-safe). -- [ ] DAW-confirm: `RENDER_TRIMEND` amplitude curve (0.00025119 ≈ -72 dB); trim-end-only - normalize (32768) does not engage fades/normalize/pad; trim never eats pre-`ENDPOS` - body. (See spec §Open questions / DAW-confirm.) - ## T2 — realtime tail (follow-on to T1) **Goal:** The parallel tail path for the M8 realtime backend, which does not drive `RENDER_*`: record an 8 s-capped tail window past the range end, then **trim in a @@ -141,14 +96,6 @@ then trims at the -72 dB decay point (± inherent realtime tolerance); realtime - [ ] Realtime shell: read the recorded wav PCM into a float buffer, find the trim frame, rewrite the file truncated (new I/O the backend does not do today). -## Milestone T open questions -- **Auto as the shipped-action default?** Whether `CAPTURE_ITEM`/`CAPTURE_TRACK` - (currently all TailMode::None) flip to Auto, gain a "…with tail" variant, or take - a modifier. Product call for Daniel; **leaning** paired variant / toggle over - silently changing the exact-bounds default. Not blocking T1 (the request-level - three-state contract is independent). (touches `render_settings.cpp` action table + - `actions`.) - --- # Phase D2 — Two-canvas (item-level mode projection; additive to D1)