335 lines
14 KiB
C++
335 lines
14 KiB
C++
// Standalone tests for reasampler::realtime_record — 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/realtime_record.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("realtime_record: all tests passed\n");
|
|
else std::printf("realtime_record: %d CHECK(s) FAILED\n", g_fail);
|
|
return g_fail == 0 ? 0 : 1;
|
|
}
|