// 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 #include 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; }