a0220e8c57
Loosens the exact-bounds gate against REAPER's edge rounding; deletes the bytes a BoundsMismatch refusal writes, per prune_fs's self-cleanup carve-out.
390 lines
19 KiB
C++
390 lines
19 KiB
C++
// Standalone tests for reasampler::render_settings — no REAPER, no framework.
|
|
// 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/core/capture/render_settings.h"
|
|
|
|
#include <cmath>
|
|
#include <cstdio>
|
|
#include <set>
|
|
#include <string>
|
|
|
|
using namespace reasampler;
|
|
using namespace reasampler::capture;
|
|
|
|
static int g_fail = 0;
|
|
#define CHECK(cond) do { if(!(cond)) { \
|
|
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
|
|
|
|
// --- renderSettingsFor: wet-only bit mapping ---------------------------------
|
|
|
|
static void testMasterMixWet() {
|
|
// Master mix -> value 0 (no source bits), supported.
|
|
RenderSettingsChoice c = renderSettingsFor(SourceMode::MasterMix, 1.0);
|
|
CHECK(c.settings == kRenderMasterMix);
|
|
CHECK(c.supported);
|
|
// TimeSelection aliases master mix — same result.
|
|
CHECK(renderSettingsFor(SourceMode::TimeSelection, 1.0).settings == kRenderMasterMix);
|
|
// wetDry argument is irrelevant (all three-scope capture actions are wet); passing 0.0
|
|
// must still yield the same wet master-mix bits.
|
|
CHECK(renderSettingsFor(SourceMode::MasterMix, 0.0).settings == kRenderMasterMix);
|
|
}
|
|
|
|
static void testSelectedTracksWet() {
|
|
// Selected tracks -> via master (&128), header-confirmed.
|
|
RenderSettingsChoice c = renderSettingsFor(SourceMode::SelectedTracks, 1.0);
|
|
CHECK(c.settings == kRenderSelTracksViaMaster);
|
|
CHECK(c.supported);
|
|
}
|
|
|
|
static void testSelectedItemsSingleFile() {
|
|
// Items render to ONE file (single-file bit set) so N items -> 1 bank entry.
|
|
RenderSettingsChoice c = renderSettingsFor(SourceMode::SelectedItems, 1.0);
|
|
CHECK((c.settings & kRenderSelItems) != 0);
|
|
CHECK((c.settings & kRenderSingleFile) != 0);
|
|
CHECK(c.supported);
|
|
}
|
|
|
|
static void testRazorSingleFile() {
|
|
// Razor edits render to ONE file (same single-file rationale as items).
|
|
RenderSettingsChoice c = renderSettingsFor(SourceMode::RazorArea, 1.0);
|
|
CHECK((c.settings & kRenderRazorEdits) != 0);
|
|
CHECK((c.settings & kRenderSingleFile) != 0);
|
|
CHECK(c.supported);
|
|
}
|
|
|
|
static void testRealtimeIsUnsupportedOffline() {
|
|
// The realtime mode is not an offline-render source — must report unsupported
|
|
// so the offline backend refuses it rather than rendering the master mix.
|
|
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);
|
|
}
|
|
|
|
// --- realtimeRecordWindowEnd: the T2 record-window extension -----------------
|
|
|
|
static void testRealtimeWindowNoneIsExact() {
|
|
// None -> the exact range end, no extra recording (byte-identical to today).
|
|
CHECK(realtimeRecordWindowEnd(TailMode::None, 12.5, 2000.0) == 12.5);
|
|
// manualTailMs is ignored for None.
|
|
CHECK(realtimeRecordWindowEnd(TailMode::None, 12.5, 0.0) == 12.5);
|
|
}
|
|
|
|
static void testRealtimeWindowAutoAddsCap() {
|
|
// Auto -> range end + the 8 s runaway cap (trimmed later by the decay scan).
|
|
CHECK(realtimeRecordWindowEnd(TailMode::Auto, 10.0, 0.0) == 10.0 + kMaxTailSeconds);
|
|
// manualTailMs is ignored for Auto (the cap is fixed).
|
|
CHECK(realtimeRecordWindowEnd(TailMode::Auto, 10.0, 3000.0) == 10.0 + kMaxTailSeconds);
|
|
}
|
|
|
|
static void testRealtimeWindowManualAddsClampedLength() {
|
|
// Manual -> range end + the set length in seconds (fixed, no trim).
|
|
CHECK(realtimeRecordWindowEnd(TailMode::Manual, 5.0, 2000.0) == 5.0 + 2.0);
|
|
// Clamped to the 8 s cap: > 8000 ms -> +8 s.
|
|
CHECK(realtimeRecordWindowEnd(TailMode::Manual, 5.0, 9000.0) == 5.0 + kMaxTailSeconds);
|
|
// Negative floors to 0 -> no extra window (never records before the range end).
|
|
CHECK(realtimeRecordWindowEnd(TailMode::Manual, 5.0, -100.0) == 5.0);
|
|
}
|
|
|
|
// --- parseRazorEdits: P_RAZOREDITS string -> ranges --------------------------
|
|
|
|
static void testParseSingleTrackAudioArea() {
|
|
// One track-audio triple: start end "" (empty quoted GUID = track audio).
|
|
auto r = parseRazorEdits("1.5 3.25 \"\"");
|
|
CHECK(r.size() == 1);
|
|
CHECK(r[0].startSeconds == 1.5);
|
|
CHECK(r[0].endSeconds == 3.25);
|
|
}
|
|
|
|
static void testParseMultipleAreas() {
|
|
auto r = parseRazorEdits("0.0 1.0 \"\" 2.0 4.0 \"\"");
|
|
CHECK(r.size() == 2);
|
|
CHECK(r[0].startSeconds == 0.0 && r[0].endSeconds == 1.0);
|
|
CHECK(r[1].startSeconds == 2.0 && r[1].endSeconds == 4.0);
|
|
}
|
|
|
|
static void testParseSkipsEnvelopeLaneAreas() {
|
|
// A triple whose GUID is a real {…} is an ENVELOPE-lane area — skipped, since
|
|
// razor captures render track audio only. Only the track-audio triple survives.
|
|
auto r = parseRazorEdits(
|
|
"1.0 2.0 \"\" 3.0 4.0 {AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE}");
|
|
CHECK(r.size() == 1);
|
|
CHECK(r[0].startSeconds == 1.0 && r[0].endSeconds == 2.0);
|
|
}
|
|
|
|
static void testParseEmptyAndMalformed() {
|
|
CHECK(parseRazorEdits("").empty());
|
|
// Empty/inverted range dropped (end <= start).
|
|
CHECK(parseRazorEdits("5.0 5.0 \"\"").empty());
|
|
CHECK(parseRazorEdits("5.0 1.0 \"\"").empty());
|
|
// Trailing garbage token in a time field -> that triple dropped, not a crash.
|
|
CHECK(parseRazorEdits("1.0x 2.0 \"\"").empty());
|
|
// A dangling partial triple (missing GUID token) is ignored.
|
|
CHECK(parseRazorEdits("1.0 2.0").empty());
|
|
}
|
|
|
|
static void testRazorUnionBounds() {
|
|
// Union = min start .. max end across all areas (the exact render window).
|
|
std::vector<RazorRange> ranges = {{2.0, 3.0}, {0.5, 1.0}, {4.0, 6.5}};
|
|
RazorRange u = razorUnionBounds(ranges);
|
|
CHECK(u.startSeconds == 0.5);
|
|
CHECK(u.endSeconds == 6.5);
|
|
// Empty -> {0,0} sentinel (caller treats as "no razor area").
|
|
RazorRange empty = razorUnionBounds({});
|
|
CHECK(empty.startSeconds == 0.0 && empty.endSeconds == 0.0);
|
|
}
|
|
|
|
// --- sourceModeForScope: scope -> render source mode -------------------------
|
|
|
|
static void testScopeSourceModes() {
|
|
// Track scope always renders its selected tracks (there is no master scope — to
|
|
// capture the master you render a track), whatever the window.
|
|
CHECK(sourceModeForScope(CaptureScope::Track, true) == SourceMode::SelectedTracks);
|
|
CHECK(sourceModeForScope(CaptureScope::Track, false) == SourceMode::SelectedTracks);
|
|
// Item scope renders the selected items only when their extent already prints
|
|
// the requested window.
|
|
CHECK(sourceModeForScope(CaptureScope::Item, true) == SourceMode::SelectedItems);
|
|
// Every scope's source mode is an offline-supported render source.
|
|
CHECK(renderSettingsFor(sourceModeForScope(CaptureScope::Item, true), 1.0).supported);
|
|
CHECK(renderSettingsFor(sourceModeForScope(CaptureScope::Track, true), 1.0).supported);
|
|
}
|
|
|
|
static void testRangedItemScopeRendersTimeBounded() {
|
|
// The widening defect pinned at the bit level. A window the item extent does NOT
|
|
// print must never reach the &32 selected-items source: that source is INFERRED
|
|
// (unverified — src/core/capture/CLAUDE.md §Gotchas) to take its bounds from the
|
|
// item extents, so RENDER_STARTPOS/ENDPOS cannot narrow it and the capture widens
|
|
// to the whole item. The ranged item capture renders through the selected-tracks
|
|
// source instead, which IS time-bounded.
|
|
const SourceMode ranged = sourceModeForScope(CaptureScope::Item, false);
|
|
CHECK(ranged == SourceMode::SelectedTracks);
|
|
|
|
const RenderSettingsChoice c = renderSettingsFor(ranged, 1.0);
|
|
CHECK(c.supported);
|
|
CHECK((c.settings & kRenderSelItems) == 0); // the widening bit is absent
|
|
CHECK((c.settings & kRenderSingleFile) == 0); // and its single-file companion
|
|
CHECK(c.settings == kRenderSelTracksViaMaster);
|
|
|
|
// The regression floor, at the same resolution: an item capture whose window IS
|
|
// the item extent still renders through &32 | single-file, unchanged.
|
|
const RenderSettingsChoice floorCase =
|
|
renderSettingsFor(sourceModeForScope(CaptureScope::Item, true), 1.0);
|
|
CHECK((floorCase.settings & kRenderSelItems) != 0);
|
|
CHECK((floorCase.settings & kRenderSingleFile) != 0);
|
|
|
|
// FX scope is orthogonal to the re-source: a ranged item capture still hears
|
|
// take/item FX only (this is what makes the swap safe).
|
|
CHECK(fxBypassPlanFor(CaptureScope::Item).bypassSelfFx);
|
|
}
|
|
|
|
static void testMultiTrackRangedItemRenderIsNamedForRefusal() {
|
|
// The one shape that cannot land: a ranged item capture (item scope re-sourced to
|
|
// the selected-tracks render) whose items span more than one track. That source
|
|
// is INFERRED to render one file per track with no single-file bit available
|
|
// (unverified; see src/shell/capture/CLAUDE.md §Gotchas for what that inference
|
|
// rests on), so N stems would collapse onto one render pattern and one track's
|
|
// audio would land as a successful capture.
|
|
CHECK(isMultiTrackRangedItemRender(CaptureScope::Item,
|
|
SourceMode::SelectedTracks, 2));
|
|
CHECK(isMultiTrackRangedItemRender(CaptureScope::Item,
|
|
SourceMode::SelectedTracks, 7));
|
|
|
|
// One track is the whole point of the re-source — it must still render.
|
|
CHECK(!isMultiTrackRangedItemRender(CaptureScope::Item,
|
|
SourceMode::SelectedTracks, 1));
|
|
CHECK(!isMultiTrackRangedItemRender(CaptureScope::Item,
|
|
SourceMode::SelectedTracks, 0));
|
|
|
|
// A full-extent item capture keeps the selected-items source, whose single-file
|
|
// bit already sums a multi-track item selection into one file.
|
|
CHECK(!isMultiTrackRangedItemRender(CaptureScope::Item,
|
|
SourceMode::SelectedItems, 3));
|
|
|
|
// Track scope renders through the same source and is deliberately untouched here
|
|
// — that per-track output predates the item re-source and is filed in docs/TODO.md.
|
|
CHECK(!isMultiTrackRangedItemRender(CaptureScope::Track,
|
|
SourceMode::SelectedTracks, 3));
|
|
|
|
// The predicate is reachable from the mapping it guards: the ranged item capture's
|
|
// own source mode is the one it names.
|
|
CHECK(isMultiTrackRangedItemRender(CaptureScope::Item,
|
|
sourceModeForScope(CaptureScope::Item, false), 2));
|
|
CHECK(!isMultiTrackRangedItemRender(CaptureScope::Item,
|
|
sourceModeForScope(CaptureScope::Item, true), 2));
|
|
}
|
|
|
|
// --- inferRangeSource: razor-else-time (orthogonal to scope) -----------------
|
|
|
|
static void testRangeInference() {
|
|
// Razor present -> razor union wins; no razor -> time selection.
|
|
CHECK(inferRangeSource(true) == RangeSource::Razor);
|
|
CHECK(inferRangeSource(false) == RangeSource::TimeSelection);
|
|
}
|
|
|
|
// --- fxBypassPlanFor: the FX-scope invariant ----------------------------------
|
|
|
|
static void testItemScopeBypassesEverythingButTake() {
|
|
// Item = take/item FX ONLY. Bypass the item's own track FX, its ancestors, and
|
|
// the master. (If this returned bypassSelfFx=false the M7 defect — items heard
|
|
// through the track's FX — would recur; the assertion pins the fix.)
|
|
FxBypassPlan p = fxBypassPlanFor(CaptureScope::Item);
|
|
CHECK(p.bypassSelfFx);
|
|
CHECK(p.bypassAncestorFx);
|
|
CHECK(p.bypassMaster);
|
|
}
|
|
|
|
static void testTrackScopeKeepsSelfBypassesAncestorsAndMaster() {
|
|
// Track = item FX + the track's OWN FX. Keep self FX; bypass ancestors + master.
|
|
// Master stays a bypass target even though it is no longer a capture scope.
|
|
FxBypassPlan p = fxBypassPlanFor(CaptureScope::Track);
|
|
CHECK(!p.bypassSelfFx); // the whole point: the track's own FX stays live
|
|
CHECK(p.bypassAncestorFx); // no parent/folder FX
|
|
CHECK(p.bypassMaster); // no master FX
|
|
}
|
|
|
|
// --- captureActionTable: the scope taxonomy ----------------------------------
|
|
|
|
static void testTableHasBothScopes() {
|
|
const auto& table = captureActionTable();
|
|
// 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<std::string> ids;
|
|
int item = 0, track = 0;
|
|
for (const auto& def : table) {
|
|
// Every command SUFFIX (Phase V, V4 — the channel prefix is prepended by the shell)
|
|
// is a non-empty, UNIQUE string (duplicate suffixes would collide once composed).
|
|
std::string suffix = def.commandSuffix;
|
|
CHECK(!suffix.empty());
|
|
// The suffix is NOT prefixed with the channel family here — that is composed at
|
|
// register time. A leftover "CEREBELLUM_REASAMPLER_" in the table would be a
|
|
// double-prefix bug, so assert its ABSENCE.
|
|
CHECK(suffix.rfind("CEREBELLUM_REASAMPLER_", 0) != 0);
|
|
CHECK(ids.insert(suffix).second); // false if duplicate
|
|
// Every scope resolves to a supported offline source, on BOTH the
|
|
// extent-prints-the-window path and the time-bounded one.
|
|
CHECK(renderSettingsFor(sourceModeForScope(def.scope, true), 1.0).supported);
|
|
CHECK(renderSettingsFor(sourceModeForScope(def.scope, false), 1.0).supported);
|
|
|
|
if (def.scope == CaptureScope::Item) ++item;
|
|
if (def.scope == CaptureScope::Track) ++track;
|
|
}
|
|
// Exactly one row per scope — no dupes, no gaps, no tail variants.
|
|
CHECK(item == 1);
|
|
CHECK(track == 1);
|
|
}
|
|
|
|
static void testScopeActionIdsAreTheShippedStrings() {
|
|
// Pin the shipped CAPTURE_ITEM / CAPTURE_TRACK SUFFIXES so a future edit that silently
|
|
// changes them (breaking user keybindings once composed with the channel prefix) fails
|
|
// the gate. The full stable id is prefix + suffix ("CEREBELLUM_REASAMPLER_CAPTURE_ITEM").
|
|
const auto& table = captureActionTable();
|
|
std::string itemId, trackId;
|
|
for (const auto& def : table) {
|
|
if (def.scope == CaptureScope::Item) itemId = def.commandSuffix;
|
|
if (def.scope == CaptureScope::Track) trackId = def.commandSuffix;
|
|
}
|
|
CHECK(itemId == "CAPTURE_ITEM");
|
|
CHECK(trackId == "CAPTURE_TRACK");
|
|
}
|
|
|
|
int main() {
|
|
testMasterMixWet();
|
|
testSelectedTracksWet();
|
|
testSelectedItemsSingleFile();
|
|
testRazorSingleFile();
|
|
testRealtimeIsUnsupportedOffline();
|
|
testTailNoneIsExactBounds();
|
|
testTailAutoIsSurgicalTrim();
|
|
testAutoTrimRatioDerivesFromDb();
|
|
testTailManualFixedNoTrim();
|
|
testTailManualClampsToCap();
|
|
testRealtimeWindowNoneIsExact();
|
|
testRealtimeWindowAutoAddsCap();
|
|
testRealtimeWindowManualAddsClampedLength();
|
|
testParseSingleTrackAudioArea();
|
|
testParseMultipleAreas();
|
|
testParseSkipsEnvelopeLaneAreas();
|
|
testParseEmptyAndMalformed();
|
|
testRazorUnionBounds();
|
|
testScopeSourceModes();
|
|
testRangedItemScopeRendersTimeBounded();
|
|
testMultiTrackRangedItemRenderIsNamedForRefusal();
|
|
testRangeInference();
|
|
testItemScopeBypassesEverythingButTake();
|
|
testTrackScopeKeepsSelfBypassesAncestorsAndMaster();
|
|
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);
|
|
return g_fail == 0 ? 0 : 1;
|
|
}
|