Q-W3: main.cpp → pointers+entry+dispatch via 4 capture hoists; one pure wav_codec RIFF owner; ICaptureBackend deleted; capture_realtime rename + finalize split; shared stampCaptureSample; makeUniqueTag gains monotonic counter (fixes same-second batch collisions). 60/60 green.

This commit is contained in:
2026-07-29 10:56:11 -04:00
parent d7d7f7e084
commit 09f7173db2
29 changed files with 2972 additions and 2426 deletions
+335
View File
@@ -0,0 +1,335 @@
// Standalone tests for reasampler::capture_realtime (renamed from realtime_record
// in Q-W3 — the Q-9 naming rider) — no REAPER, no framework.
// Covers the two pure pieces behind the realtime-record backend (M8): the
// record-mode/recipe bookkeeping (channel count + tap -> I_RECMODE / I_RECMODE_FLAGS)
// and the wet/dry -> tap decision, plus the recorded-file -> Sample mapping.
#include "../src/core/capture/capture_realtime.h"
#include <cstdio>
#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)
// --- recordModePlanFor: channel count -> stereo/mono, latency-compensated -----
static void testStereoOutForTwoChannels() {
// A 2-channel request records stereo-out, latency-compensated (I_RECMODE 3).
RecordModePlan p = recordModePlanFor(2, OutputTap::PostFader);
CHECK(p.recMode == kRecModeStereoOutLatComp);
CHECK(p.recMode == 3);
}
static void testMonoOutForOneChannel() {
// A 1-channel request records mono-out, latency-compensated (I_RECMODE 6).
RecordModePlan p = recordModePlanFor(1, OutputTap::PostFader);
CHECK(p.recMode == kRecModeMonoOutLatComp);
CHECK(p.recMode == 6);
// Zero/negative channel counts also fall to mono-out (defensive, <= 1).
CHECK(recordModePlanFor(0, OutputTap::PostFader).recMode == kRecModeMonoOutLatComp);
}
static void testMoreThanTwoChannelsStillStereoOut() {
// >2 channels still record stereo-out — REAPER's output-record modes are
// mono/stereo only. (A >2ch realtime capture is out of this increment's scope.)
CHECK(recordModePlanFor(4, OutputTap::PostFader).recMode == kRecModeStereoOutLatComp);
}
// --- recordModePlanFor: tap -> I_RECMODE_FLAGS &3 bits -------------------------
static void testPostFaderTapFlags() {
// PostFader = fully wet, &3==0.
CHECK(recordModePlanFor(2, OutputTap::PostFader).recModeFlags == kRecOutPostFader);
CHECK((recordModePlanFor(2, OutputTap::PostFader).recModeFlags & 3) == 0);
}
static void testPreFxTapFlags() {
// PreFx = true dry, &3==1 — the only documented pre-FX tap in the SDK.
CHECK(recordModePlanFor(2, OutputTap::PreFx).recModeFlags == kRecOutPreFx);
CHECK((recordModePlanFor(2, OutputTap::PreFx).recModeFlags & 3) == 1);
}
static void testPostFxPreFaderTapFlags() {
// PostFxPreFader = wet FX, pre-fader, &3==2.
CHECK(recordModePlanFor(2, OutputTap::PostFxPreFader).recModeFlags == kRecOutPostFxPreFader);
CHECK((recordModePlanFor(2, OutputTap::PostFxPreFader).recModeFlags & 3) == 2);
}
static void testTapIsIndependentOfChannelCount() {
// The tap bits do not vary with channel count; the record mode does not vary
// with the tap. The two axes are orthogonal.
CHECK(recordModePlanFor(1, OutputTap::PreFx).recModeFlags == kRecOutPreFx);
CHECK(recordModePlanFor(4, OutputTap::PreFx).recModeFlags == kRecOutPreFx);
CHECK(recordModePlanFor(1, OutputTap::PreFx).recMode == kRecModeMonoOutLatComp);
CHECK(recordModePlanFor(2, OutputTap::PreFx).recMode == kRecModeStereoOutLatComp);
}
// --- outputTapForWetDry: wet -> PostFader, dry -> PreFx ------------------------
static void testFullyWetTapsPostFader() {
CHECK(outputTapForWetDry(1.0) == OutputTap::PostFader);
}
static void testDryTapsPreFx() {
// Any value below fully-wet is the true pre-FX dry tap.
CHECK(outputTapForWetDry(0.0) == OutputTap::PreFx);
CHECK(outputTapForWetDry(0.5) == OutputTap::PreFx);
// 0.999 (just under wet) still taps pre-FX — there is no blend, it is a switch.
CHECK(outputTapForWetDry(0.999) == OutputTap::PreFx);
}
// --- sampleFromRecordedCapture: exact bounds, scratch tier, no dedup ----------
static RecordedCapture makeCapture() {
RecordedCapture cap;
cap.relativePath = "reasampler_bank/realtime_1700000000.wav";
cap.uniqueTag = "1700000000";
cap.sourceMode = SourceMode::Realtime;
cap.startSeconds = 4.0;
cap.endSeconds = 6.5;
cap.wetDry = 1.0;
cap.displayName = "realtime";
cap.trackGuids = {"{GUID-A}"};
cap.channelCount = 2;
cap.sampleRate = 48000;
cap.captureTempo = 120.0;
cap.createdTimestamp = 1700000000;
return cap;
}
static void testSampleExactBoundsNoRounding() {
Sample s = sampleFromRecordedCapture(makeCapture());
// Bounds are echoed exactly — no re-measuring, no rounding.
CHECK(s.sourceRange.startSeconds == 4.0);
CHECK(s.sourceRange.endSeconds == 6.5);
CHECK(s.lengthSeconds == 2.5); // end - start, computed here
}
static void testSampleMetadataCarriedThrough() {
Sample s = sampleFromRecordedCapture(makeCapture());
CHECK(s.sourceMode == SourceMode::Realtime);
CHECK(s.relativePath == "reasampler_bank/realtime_1700000000.wav");
CHECK(s.channelCount == 2);
CHECK(s.sampleRate == 48000);
CHECK(s.captureTempo == 120.0);
CHECK(s.wetDry == 1.0);
CHECK(s.trackGuids.size() == 1);
CHECK(s.trackGuids[0] == "{GUID-A}");
CHECK(s.createdTimestamp == 1700000000);
CHECK(s.displayName == "realtime");
}
static void testSampleLandsInScratchWithNoHash() {
Sample s = sampleFromRecordedCapture(makeCapture());
// Captures land in scratch by default (auto-prunable), same as offline.
CHECK(s.tier == Tier::Scratch);
CHECK(s.isAutoPrunable());
// Empty content hash so a realtime capture never collapses (bank_model treats
// "" as non-participating in dedup) — realtime is not bit-identical, so it must
// never dedup against a prior capture.
CHECK(s.contentHash.empty());
}
static void testSampleIdIsStableAndUnique() {
Sample s = sampleFromRecordedCapture(makeCapture());
// The id carries the unique tag so repeated captures do not collide, and is
// consistent with the file that produced it (same discipline as offline).
CHECK(s.id.find("1700000000") != std::string::npos);
CHECK(!s.id.empty());
}
static void testUnknownSampleRateStaysZero() {
// When the shell could not resolve the project rate it passes 0; the mapping
// must not invent a value (mirror of the offline "unknown rate -> 0" behavior).
RecordedCapture cap = makeCapture();
cap.sampleRate = 0;
Sample s = sampleFromRecordedCapture(cap);
CHECK(s.sampleRate == 0);
}
// --- advanceRecordPhase: the async completion state machine -------------------
//
// The machine now has two waits: Recording (transport running) and Finalizing (stopped,
// waiting for the recorded file to flush). Inputs bundle the transport reading, the
// wall-clock ceilings, and the file-flush readiness. Helpers keep the tests terse.
static const double kStart = 4.0;
static const double kEnd = 6.5;
// Recording-phase inputs: transport recording flag + play position + elapsed wall clock.
static RecordTickInputs recTick(bool rec, double pos, double elapsed) {
RecordTickInputs in;
in.transport.recording = rec;
in.transport.playPosition = pos;
in.elapsedSeconds = elapsed;
return in;
}
// Finalizing-phase inputs: file readiness + time spent flushing.
static RecordTickInputs finTick(bool fileReady, double finalizing) {
RecordTickInputs in;
in.transport.recording = false; // stopped by the time we are finalizing
in.fileReady = fileReady;
in.finalizingSeconds = finalizing;
return in;
}
static RecordPhase advance(RecordPhase cur, const RecordTickInputs& in) {
return advanceRecordPhase(cur, in, kStart, kEnd);
}
// -- Recording -> keep waiting / reached end / stopped early --------------------
static void testStaysRecordingBeforeRangeEnd() {
// Cursor short of the end, well under the wall-clock ceiling -> keep waiting.
RecordPhase p = advance(RecordPhase::Recording, recTick(true, 5.0, 1.0));
CHECK(p == RecordPhase::Recording);
CHECK(!isTerminalPhase(p));
CHECK(!isStopRequested(p));
}
static void testReachesEndAtOrPastRangeEnd() {
// Cursor exactly on the end goes to Finalizing (>=, not >), and past the end too.
CHECK(advance(RecordPhase::Recording, recTick(true, 6.5, 3.0))
== RecordPhase::Finalizing);
CHECK(advance(RecordPhase::Recording, recTick(true, 7.0, 3.0))
== RecordPhase::Finalizing);
// Finalizing is the shell's stop-and-flush signal, not yet terminal.
CHECK(isStopRequested(RecordPhase::Finalizing));
CHECK(!isTerminalPhase(RecordPhase::Finalizing));
}
static void testStopsEarlyGoesToFinalizing() {
// Transport no longer recording (user hit stop) before the end -> Finalizing,
// regardless of where the cursor was.
RecordPhase p = advance(RecordPhase::Recording, recTick(false, 5.0, 1.0));
CHECK(p == RecordPhase::Finalizing);
CHECK(isStopRequested(p));
}
static void testStopBeatsCursorPositionCheck() {
// NOT recording is the signal even if the cursor sits past the end — a stop that
// raced the end is still a stop; both routes converge on Finalizing anyway.
CHECK(advance(RecordPhase::Recording, recTick(false, 9.0, 1.0))
== RecordPhase::Finalizing);
}
static void testReachedEndImmediatelyOnFirstTick() {
// A degenerate range where the cursor is already at/past end on the first tick
// moves to Finalizing at once rather than waiting a full transport lap.
CHECK(advance(RecordPhase::Recording, recTick(true, 6.5, 0.1))
== RecordPhase::Finalizing);
}
// -- Recording safety ceiling (review §3): stuck/non-advancing transport --------
static void testStuckTransportTripsWallClockCeiling() {
// Transport reports recording, but the cursor never advances to the end. Before the
// ceiling: keep waiting. Past (end-start)+margin of wall clock: force Finalizing so
// the temp track + armed sink are not leaked for the session.
const double duration = kEnd - kStart; // 2.5s nominal
const double underCeiling = duration + kRecordMarginSeconds - 0.5;
const double overCeiling = duration + kRecordMarginSeconds + 0.5;
// Cursor stuck at start the whole time.
CHECK(advance(RecordPhase::Recording, recTick(true, kStart, underCeiling))
== RecordPhase::Recording);
CHECK(advance(RecordPhase::Recording, recTick(true, kStart, overCeiling))
== RecordPhase::Finalizing);
}
// -- Finalizing (review §2): deferred finalize, flush wait ----------------------
static void testFinalizingWaitsUntilFileReady() {
// File not yet flushed/stable, within the flush ceiling -> keep waiting in
// Finalizing (do NOT move the file mid-write).
RecordPhase p = advance(RecordPhase::Finalizing, finTick(false, 1.0));
CHECK(p == RecordPhase::Finalizing);
CHECK(!isTerminalPhase(p));
}
static void testFinalizingCompletesWhenFileReady() {
// File exists AND is stable -> Done (the shell now moves it + builds the Sample).
RecordPhase p = advance(RecordPhase::Finalizing, finTick(true, 1.0));
CHECK(p == RecordPhase::Done);
CHECK(isTerminalPhase(p));
}
static void testFinalizingFailsWhenFlushCeilingTrips() {
// File never stabilizes; past the flush ceiling -> Failed (give up, RenderFailed).
const double overCeiling = kFinalizeFlushCeilingSeconds + 0.5;
RecordPhase p = advance(RecordPhase::Finalizing, finTick(false, overCeiling));
CHECK(p == RecordPhase::Failed);
CHECK(isTerminalPhase(p));
}
static void testFinalizingReadyBeatsCeiling() {
// If the file is ready ON the same tick the ceiling trips, ready wins -> Done
// (we do not discard a capture that just became available).
const double overCeiling = kFinalizeFlushCeilingSeconds + 0.5;
CHECK(advance(RecordPhase::Finalizing, finTick(true, overCeiling))
== RecordPhase::Done);
}
// -- Terminal stickiness + classification --------------------------------------
static void testTerminalPhasesAreSticky() {
// Feeding a terminal phase back returns it unchanged — a stray late tick before
// teardown finishes cannot flip the verdict (the idempotence the shell relies on).
CHECK(advance(RecordPhase::Done, recTick(true, 2.0, 1.0)) == RecordPhase::Done);
CHECK(advance(RecordPhase::Done, finTick(false, 1.0)) == RecordPhase::Done);
CHECK(advance(RecordPhase::Failed, recTick(true, 8.0, 1.0)) == RecordPhase::Failed);
CHECK(advance(RecordPhase::Failed, finTick(true, 1.0)) == RecordPhase::Failed);
}
static void testStopRequestedClassification() {
// isStopRequested fires for every phase past Recording (drives the one-shot stop).
CHECK(!isStopRequested(RecordPhase::Recording));
CHECK(isStopRequested(RecordPhase::Finalizing));
CHECK(isStopRequested(RecordPhase::Done));
CHECK(isStopRequested(RecordPhase::Failed));
}
static void testIsTerminalPhaseClassification() {
CHECK(!isTerminalPhase(RecordPhase::Recording));
CHECK(!isTerminalPhase(RecordPhase::Finalizing));
CHECK(isTerminalPhase(RecordPhase::Done));
CHECK(isTerminalPhase(RecordPhase::Failed));
}
int main() {
testStereoOutForTwoChannels();
testMonoOutForOneChannel();
testMoreThanTwoChannelsStillStereoOut();
testPostFaderTapFlags();
testPreFxTapFlags();
testPostFxPreFaderTapFlags();
testTapIsIndependentOfChannelCount();
testFullyWetTapsPostFader();
testDryTapsPreFx();
testSampleExactBoundsNoRounding();
testSampleMetadataCarriedThrough();
testSampleLandsInScratchWithNoHash();
testSampleIdIsStableAndUnique();
testUnknownSampleRateStaysZero();
testStaysRecordingBeforeRangeEnd();
testReachesEndAtOrPastRangeEnd();
testStopsEarlyGoesToFinalizing();
testStopBeatsCursorPositionCheck();
testReachedEndImmediatelyOnFirstTick();
testStuckTransportTripsWallClockCeiling();
testFinalizingWaitsUntilFileReady();
testFinalizingCompletesWhenFileReady();
testFinalizingFailsWhenFlushCeilingTrips();
testFinalizingReadyBeatsCeiling();
testTerminalPhasesAreSticky();
testStopRequestedClassification();
testIsTerminalPhaseClassification();
if (g_fail == 0) std::printf("capture_realtime: all tests passed\n");
else std::printf("capture_realtime: %d CHECK(s) FAILED\n", g_fail);
return g_fail == 0 ? 0 : 1;
}