// render_settings.cpp — pure logic for the three-scope capture action family. See header. // NO REAPER types; unit-tested by tests/test_render_settings.cpp. #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; } double realtimeRecordWindowEnd(TailMode mode, double rangeEndSeconds, double manualTailMs) { switch (mode) { case TailMode::None: // Exact — no extra recording (byte-identical to today's realtime capture). return rangeEndSeconds; case TailMode::Auto: // The 8 s runaway cap past the range end; the decay-trim shortens it later. return rangeEndSeconds + kMaxTailSeconds; case TailMode::Manual: // Fixed window: range + the set length, clamped to the 8 s cap (the same // runaway guard the offline Manual path applies). Negative floors to 0. return rangeEndSeconds + std::clamp(manualTailMs, 0.0, kMaxTailMs) / 1000.0; } // Unreachable for a valid enum; fail closed to exact bounds (never a stray tail). return rangeEndSeconds; } 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 // handled by fxBypassPlanFor, not by these render bits. RenderSettingsChoice c; switch (mode) { case SourceMode::MasterMix: case SourceMode::TimeSelection: // Master IS the mix — wet-only; &(1|2)==0, no source bits. c.settings = kRenderMasterMix; c.supported = true; return c; case SourceMode::SelectedTracks: // Selected tracks via master (&128) — wet (post-FX). Header ~3041. c.settings = kRenderSelTracksViaMaster; c.supported = true; return c; case SourceMode::SelectedItems: // Selected media items, rendered to ONE file (single-file bit) so a // multi-item selection yields a single bank entry, not N wavs. c.settings = kRenderSelItems | kRenderSingleFile; c.supported = true; return c; case SourceMode::RazorArea: // Render razor edits to ONE file (same single-file rationale as items). c.settings = kRenderRazorEdits | kRenderSingleFile; c.supported = true; return c; case SourceMode::Realtime: // Not an offline-render source — the realtime backend (M8) owns it. c.settings = kRenderMasterMix; c.supported = false; return c; } // Unreachable for a valid enum; fail closed (unsupported) rather than render. c.supported = false; return c; } SourceMode sourceModeForScope(CaptureScope scope) { switch (scope) { case CaptureScope::Item: return SourceMode::SelectedItems; case CaptureScope::Track: return SourceMode::SelectedTracks; } return SourceMode::SelectedItems; // unreachable for a valid enum; fail closed } RangeSource inferRangeSource(bool hasRazorArea) { // Razor wins when present; otherwise the time selection. Orthogonal to scope. return hasRazorArea ? RangeSource::Razor : RangeSource::TimeSelection; } FxBypassPlan fxBypassPlanFor(CaptureScope scope) { FxBypassPlan p; switch (scope) { case CaptureScope::Item: // Item = take/item FX ONLY. Bypass the item's own track FX, every // ancestor's FX, and the master's FX. (Take FX live in the item and // are always rendered — there is no track to bypass them from.) p.bypassSelfFx = true; p.bypassAncestorFx = true; p.bypassMaster = true; return p; case CaptureScope::Track: // Track = item FX + the selected track's OWN FX. Keep self FX; bypass // every ancestor (parent/folder) and the master. Parent/master GAIN // still applies (I_FXEN is FX-only) — documented boundary. p.bypassSelfFx = false; p.bypassAncestorFx = true; p.bypassMaster = true; return p; } return p; // unreachable; bypass nothing (fail to full-chain, never over-bypass) } std::vector parseRazorEdits(const std::string& razorString) { std::vector ranges; std::istringstream in(razorString); // The string is space-separated TRIPLES: . // A track-audio area's third token is the literal two-char string `""`; an // envelope-lane area's is a GUID `{…}`. We keep only track-audio triples. std::string startTok, endTok, guidTok; while (in >> startTok >> endTok >> guidTok) { // Envelope-lane areas carry a real GUID; skip them (razor captures track audio only). // A track-audio area's GUID token is the empty quoted string `""`. if (guidTok != "\"\"") continue; // Parse the two time tokens. std::stod throws on garbage — guard so one // malformed triple does not abort the whole parse. double start = 0.0, end = 0.0; try { std::size_t sp = 0, ep = 0; start = std::stod(startTok, &sp); end = std::stod(endTok, &ep); // Reject tokens with trailing garbage (e.g. "1.0x") — a partial parse // is a malformed area, not a valid range. if (sp != startTok.size() || ep != endTok.size()) continue; } catch (...) { continue; } if (end > start) ranges.push_back({start, end}); // drop empty/inverted } return ranges; } RazorRange razorUnionBounds(const std::vector& ranges) { if (ranges.empty()) return {0.0, 0.0}; RazorRange u = ranges.front(); for (const RazorRange& r : ranges) { if (r.startSeconds < u.startSeconds) u.startSeconds = r.startSeconds; if (r.endSeconds > u.endSeconds) u.endSeconds = r.endSeconds; } return u; } const std::vector& captureActionTable() { // 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 — item/take FX only. Suffix + phrase are channel-agnostic; the shell // composes the FOREVER-STABLE id (prefix + "CAPTURE_ITEM") and the display name. {"CAPTURE_ITEM", "capture selected item(s)", "item", CaptureScope::Item}, // Track scope — item FX + the track's own FX. {"CAPTURE_TRACK", "capture selected track(s)", "track", CaptureScope::Track}, }; return table; } } // namespace reasampler