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:
@@ -0,0 +1,252 @@
|
||||
// capture_realtime_finalize.cpp — the FILE-SIDE half of the realtime-record shell
|
||||
// (Q-W3, T4-08 split): recorded-file discovery, move-into-bank, the Auto-tail PCM
|
||||
// decay-scan trim, and the finished-Sample population. See the header. The async
|
||||
// record lifecycle lives in capture_realtime_shell.cpp.
|
||||
//
|
||||
// Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.h
|
||||
// WITHOUT REAPERAPI_IMPLEMENT — main.cpp is the one TU that defines the API
|
||||
// pointers; here they are extern (CLAUDE.md §contract).
|
||||
|
||||
#include "shell/capture/capture_realtime_finalize.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "core/audio/peaks.h" // lastFrameAboveThreshold
|
||||
#include "core/capture/capture_realtime.h" // RecordedCapture, sampleFromRecordedCapture
|
||||
#include "core/capture/render_settings.h" // autoTrimEndRatio
|
||||
#include "core/capture/wav_codec.h" // parseWavLayout, extractFloatFrames, planWavTruncate, patchU32LE
|
||||
#include "core/util/file_bytes.h" // shared whole-file loader (Q-W1, T2-03)
|
||||
|
||||
#define REAPERAPI_MINIMAL
|
||||
#define REAPERAPI_WANT_GetTrackNumMediaItems
|
||||
#define REAPERAPI_WANT_GetTrackMediaItem
|
||||
#define REAPERAPI_WANT_GetMediaItemTake
|
||||
#define REAPERAPI_WANT_GetMediaItemTake_Source
|
||||
#define REAPERAPI_WANT_GetMediaSourceFileName
|
||||
#include "reaper_plugin_functions.h"
|
||||
|
||||
namespace reasampler::capture {
|
||||
|
||||
namespace {
|
||||
|
||||
std::string normSlashes(std::string s) {
|
||||
for (char& c : s) if (c == '\\') c = '/';
|
||||
if (s.size() > 1 && s.back() == '/') s.pop_back();
|
||||
return s;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// §TAIL — Auto-mode PCM decay-scan trim (docs/product/capture-tail.md §realtime)
|
||||
// ============================================================================
|
||||
// After the recorded file is stable and moved into the bank (the file we OWN — never
|
||||
// the project), Auto mode trims the trailing decay: read the WAV, scan the tail
|
||||
// region (frames AFTER the original range end) backward for the last frame above
|
||||
// -72 dB, and truncate the file there. Rules (spec):
|
||||
// * no frame in the tail window above -72 dB -> trim back to the original range end
|
||||
// * signal never falls below -72 dB in window -> keep the full window (cap did its job)
|
||||
// * otherwise -> trim one frame past the last audible
|
||||
//
|
||||
// Returns the trimmed length in SECONDS (for the Sample), or a negative value to
|
||||
// signal "no trim applied" (caller keeps the pre-trim length). Best-effort and
|
||||
// non-fatal: any unreadable/unknown/short file skips the trim (keeps the full window)
|
||||
// rather than risk corrupting the capture — realtime tail is a convenience path.
|
||||
//
|
||||
// FORMAT / FLUSH ASSUMPTIONS (DAW-verify): the recorded file is a canonical 32-bit
|
||||
// float WAV (REAPER project record format — the manual procedure sets it) and is fully
|
||||
// flushed/closed before this runs (the tick() Finalizing size-stable wait guarantees
|
||||
// that for the normal path; abort()'s best-effort finalize races it, documented).
|
||||
double trimAutoTailInPlace(const std::string& path,
|
||||
double rangeStartSeconds,
|
||||
double rangeEndSeconds) {
|
||||
constexpr double kNoTrim = -1.0;
|
||||
|
||||
std::vector<std::uint8_t> bytes = util::readFileBytes(path);
|
||||
if (bytes.empty()) return kNoTrim;
|
||||
|
||||
const WavLayout layout = parseWavLayout(bytes);
|
||||
if (!layout.valid || layout.sampleRate == 0) return kNoTrim; // not a WAV we trim
|
||||
|
||||
const std::size_t totalFrames = layout.frameCount();
|
||||
if (totalFrames == 0) return kNoTrim;
|
||||
|
||||
// The original range end as a frame index within the file (frame 0 == start). Use
|
||||
// the FILE's own sample rate (authoritative) — the request rate may be 0 (=follow
|
||||
// project). Clamp to the file so a rounding overshoot cannot exceed it.
|
||||
const double rangeSeconds = rangeEndSeconds - rangeStartSeconds;
|
||||
if (rangeSeconds <= 0.0) return kNoTrim;
|
||||
std::size_t rangeEndFrame = static_cast<std::size_t>(
|
||||
rangeSeconds * static_cast<double>(layout.sampleRate) + 0.5);
|
||||
if (rangeEndFrame > totalFrames) rangeEndFrame = totalFrames;
|
||||
|
||||
// Nothing recorded past the range end (the tail window was empty) -> nothing to
|
||||
// trim; keep as-is. (Shouldn't happen for Auto, but total by construction.)
|
||||
if (rangeEndFrame >= totalFrames) return kNoTrim;
|
||||
|
||||
// Scan ONLY the tail region (frames after the original range end). The trim never
|
||||
// eats into the range body — the scan starts at rangeEndFrame.
|
||||
const std::size_t tailFrames = totalFrames - rangeEndFrame;
|
||||
const std::vector<AudioSample> tailPcm =
|
||||
extractFloatFrames(bytes, layout, rangeEndFrame, tailFrames);
|
||||
if (tailPcm.empty()) return kNoTrim;
|
||||
|
||||
const float threshold = static_cast<float>(autoTrimEndRatio());
|
||||
const std::size_t lastAbove = audio::lastFrameAboveThreshold(
|
||||
tailPcm, layout.channelCount, tailFrames, threshold);
|
||||
|
||||
// keptFrames: the total frame count the trimmed file retains.
|
||||
// no audible tail frame -> trim back to the range end (rangeEndFrame frames)
|
||||
// an audible frame at idx -> keep range body + up to and including that frame
|
||||
// The "signal never falls below threshold" case falls out naturally: lastAbove is
|
||||
// the final tail frame, so keptFrames == totalFrames (the full window is kept).
|
||||
std::size_t keptFrames;
|
||||
if (lastAbove == audio::kNoFrameAboveThreshold) {
|
||||
keptFrames = rangeEndFrame;
|
||||
} else {
|
||||
keptFrames = rangeEndFrame + (lastAbove + 1);
|
||||
}
|
||||
if (keptFrames >= totalFrames) return kNoTrim; // full window kept -> no truncate
|
||||
|
||||
const WavTruncatePlan plan = planWavTruncate(layout, keptFrames);
|
||||
if (!plan.valid) return kNoTrim;
|
||||
|
||||
// Patch the RIFF + data size fields in the in-memory buffer so they describe the
|
||||
// kept frame count (wav_codec's patch primitive — the one RIFF owner), then
|
||||
// rewrite the file as exactly the first newFileByteLength bytes (header +
|
||||
// patched sizes + retained PCM). A single truncating write is the simplest
|
||||
// correct truncate — no separate resize step, no partial-write window where the
|
||||
// on-disk sizes and length disagree. The result is a valid, playable WAV of the
|
||||
// kept frames (verified by the wav_codec re-parse test).
|
||||
patchU32LE(bytes, plan.dataSizeFieldOffset, plan.newDataSize);
|
||||
patchU32LE(bytes, plan.riffSizeFieldOffset, plan.newRiffSize);
|
||||
|
||||
// NOTE (DAW-verify, best-effort): a truncating write that fails MID-write (a full
|
||||
// disk, a yanked drive) would leave a short file while we return kNoTrim, so the
|
||||
// Sample length would overstate the file. Vanishingly unlikely for a just-recorded
|
||||
// local bank file, and realtime tail is a convenience path, so a temp-file+atomic-
|
||||
// rename is not warranted here; flagged rather than built.
|
||||
std::ofstream out(path, std::ios::binary | std::ios::trunc);
|
||||
if (!out) return kNoTrim; // could not reopen to rewrite — leave the full file
|
||||
out.write(reinterpret_cast<const char*>(bytes.data()),
|
||||
static_cast<std::streamsize>(plan.newFileByteLength));
|
||||
if (!out) return kNoTrim;
|
||||
out.close();
|
||||
|
||||
// The trimmed length in seconds for the Sample metadata.
|
||||
return static_cast<double>(keptFrames) / static_cast<double>(layout.sampleRate);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::string recordedFilePath(MediaTrack* temp) {
|
||||
if (!temp) return {};
|
||||
if (GetTrackNumMediaItems(temp) <= 0) return {};
|
||||
MediaItem* item = GetTrackMediaItem(temp, 0);
|
||||
if (!item) return {};
|
||||
MediaItem_Take* take = GetMediaItemTake(item, 0);
|
||||
if (!take) return {};
|
||||
PCM_source* src = GetMediaItemTake_Source(take);
|
||||
if (!src) return {};
|
||||
std::vector<char> buf(4096, '\0');
|
||||
GetMediaSourceFileName(src, buf.data(), static_cast<int>(buf.size()));
|
||||
return normSlashes(std::string(buf.data()));
|
||||
}
|
||||
|
||||
CaptureResult finalizeRecording(ReaProject* proj, MediaTrack* temp,
|
||||
const CaptureRequest& request,
|
||||
const BankPaths& paths,
|
||||
const std::string& uniqueTag,
|
||||
double recordWindowEnd) {
|
||||
CaptureResult result;
|
||||
|
||||
const std::string recorded = recordedFilePath(temp);
|
||||
if (recorded.empty() || !std::filesystem::exists(recorded)) {
|
||||
result.status = CaptureStatus::RenderFailed;
|
||||
result.message = "Realtime record produced no file (check transport/record "
|
||||
"settings in the DAW).";
|
||||
return result;
|
||||
}
|
||||
|
||||
std::error_code ec;
|
||||
std::filesystem::create_directories(paths.absoluteDir, ec);
|
||||
const std::string destPath = paths.absoluteDir + "/" + paths.fileName;
|
||||
std::filesystem::rename(recorded, destPath, ec);
|
||||
if (ec) {
|
||||
// Cross-volume rename can fail; fall back to copy+remove.
|
||||
ec.clear();
|
||||
std::filesystem::copy_file(
|
||||
recorded, destPath,
|
||||
std::filesystem::copy_options::overwrite_existing, ec);
|
||||
if (ec) {
|
||||
result.status = CaptureStatus::RenderFailed;
|
||||
result.message = "Recorded file could not be moved into the bank: " +
|
||||
ec.message();
|
||||
return result;
|
||||
}
|
||||
std::error_code rmEc;
|
||||
std::filesystem::remove(recorded, rmEc); // best-effort
|
||||
}
|
||||
|
||||
// TAIL (Auto): trim the trailing decay of the recorded window in place — on the
|
||||
// BANK file we now own (destPath), never the project. Best-effort: an unreadable /
|
||||
// unknown-format / short file skips the trim (keeps the full window) rather than
|
||||
// corrupt the capture. Only Auto trims; None recorded exact bounds and Manual is a
|
||||
// fixed window (spec §The realtime path). Returns the trimmed length in seconds,
|
||||
// or < 0 for "no trim applied".
|
||||
double trimmedLenSeconds = -1.0;
|
||||
if (request.tailMode == TailMode::Auto) {
|
||||
trimmedLenSeconds = trimAutoTailInPlace(destPath,
|
||||
request.startSeconds,
|
||||
request.endSeconds);
|
||||
}
|
||||
|
||||
// The pure recorded-capture -> Sample mapping (identity, bounds echo, tier).
|
||||
RecordedCapture cap;
|
||||
cap.relativePath = paths.relativePath;
|
||||
cap.uniqueTag = uniqueTag;
|
||||
cap.sourceMode = SourceMode::Realtime;
|
||||
cap.startSeconds = request.startSeconds;
|
||||
cap.endSeconds = request.endSeconds;
|
||||
cap.wetDry = request.wetDry;
|
||||
cap.displayName = request.baseName;
|
||||
cap.trackGuids = request.trackGuids;
|
||||
cap.channelCount = request.channelCount;
|
||||
|
||||
result.status = CaptureStatus::Ok;
|
||||
result.sample = sampleFromRecordedCapture(cap);
|
||||
|
||||
// The SHARED finished-capture stamp (T2-09 dedupe): trackGuids + channelCount
|
||||
// (request echo), resolved sampleRate (request rate else PROJECT_SRATE — read
|
||||
// against the record's OWN project), captureTempo, the capture-start time
|
||||
// signature (timeSigProj = proj: the realtime path PINS the record's own
|
||||
// project — the divergence from offline's active-project read, kept
|
||||
// caller-visible here), the WAV-aware contentHash of the (possibly trimmed)
|
||||
// bank file, and createdTimestamp.
|
||||
stampCaptureSample(result.sample, request, /*rateProj=*/proj,
|
||||
/*timeSigProj=*/proj, destPath);
|
||||
|
||||
// The recorded file's true length differs from the request range when a tail was
|
||||
// recorded, so the Sample length must reflect the FILE, not the range:
|
||||
// Auto with a trim applied -> the trimmed length trimAutoTailInPlace returned.
|
||||
// Auto with no trim, or Manual -> the full recorded window (end - start).
|
||||
// None -> the exact range (unchanged; recordWindowEnd == endSeconds).
|
||||
// sampleFromRecordedCapture already set lengthSeconds = end - start; override it
|
||||
// to the recorded/trimmed length so downstream (thumbnail, placement) matches disk.
|
||||
if (trimmedLenSeconds >= 0.0) {
|
||||
result.sample.lengthSeconds = trimmedLenSeconds;
|
||||
} else {
|
||||
result.sample.lengthSeconds = recordWindowEnd - request.startSeconds;
|
||||
}
|
||||
|
||||
result.message = "Realtime-captured [" +
|
||||
std::to_string(request.startSeconds) + "s, " +
|
||||
std::to_string(request.endSeconds) + "s] (recorded " +
|
||||
std::to_string(result.sample.lengthSeconds) + "s) -> " +
|
||||
paths.relativePath;
|
||||
return result;
|
||||
}
|
||||
|
||||
} // namespace reasampler::capture
|
||||
Reference in New Issue
Block a user