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
+90 -59
View File
@@ -1,5 +1,5 @@
#include "core/namespaces.h"
// capture.cpp — REAPER-facing offline-render backend (OfflineRenderBackend).
// capture.cpp — REAPER-facing offline-render backend (OfflineRenderBackend) plus
// the shared backend helpers (makeUniqueTag / stampCaptureSample — Q-W3 riders).
//
// Compiled into the reaper_reasampler MODULE. Includes
// reaper_plugin_functions.h WITHOUT REAPERAPI_IMPLEMENT — main.cpp is the one TU
@@ -36,6 +36,7 @@
#include "shell/capture/capture.h"
#include <atomic>
#include <cstdint>
#include <ctime>
#include <filesystem>
@@ -44,6 +45,7 @@
#include <vector>
#include "core/capture/capture_paths.h"
#include "core/capture/wav_codec.h" // hashWavContent — the one WAV/RIFF owner
#include "core/util/file_bytes.h"
#include "core/capture/render_settings.h"
@@ -58,7 +60,7 @@
#define REAPERAPI_WANT_TimeMap_GetTimeSigAtTime
#include "reaper_plugin_functions.h"
namespace reasampler {
namespace reasampler::capture {
namespace {
@@ -219,21 +221,84 @@ struct ScopedRenderSettings {
ScopedRenderSettings& operator=(const ScopedRenderSettings&) = delete;
};
// A monotonic, filesystem-safe timestamp tag so repeated captures in one session
// do not collide on the file name. NOTE: the tag varies the file NAME, not the
// audio bytes — bit-identical-repeat is about identical *content* for identical
// requests; two deliberate captures naturally live in two files.
std::string makeUniqueTag() {
std::time_t now = std::time(nullptr);
return std::to_string(static_cast<long long>(now));
}
// Whole-file reads go through the shared core/util readFileBytes (Q-W1, T2-03):
// empty on any I/O failure (the caller then leaves contentHash empty — the safe,
// confirm-eliciting direction for an unreadable file).
} // namespace
// --- Shared backend helpers (Q-W3 riders — see capture.h) --------------------
std::string makeUniqueTag(const std::string& prefix) {
// Timestamp + PER-SESSION MONOTONIC counter (T1-11 fix). The timestamp alone
// had one-second resolution: two captures of the same baseName within the same
// wall-clock second derived the same file stem, so the second render silently
// overwrote the first file and minted two Samples with colliding ids —
// reachable in practice via batch capture. The counter (shared across both
// backends — this is the one definition both call) makes every tag of a
// session distinct regardless of timing. NOTE: the tag varies the file NAME,
// not the audio bytes — bit-identical-repeat is about identical *content* for
// identical requests; two deliberate captures naturally live in two files.
static std::atomic<unsigned long long> counter{0};
const std::time_t now = std::time(nullptr);
return prefix + std::to_string(static_cast<long long>(now)) + "-" +
std::to_string(++counter);
}
void stampCaptureSample(Sample& s, const CaptureRequest& req,
ReaProject* rateProj, ReaProject* timeSigProj,
const std::string& absolutePath) {
// Track GUIDs + channel count: echoed from the request (the caller resolved
// the selection; the backends stay source-agnostic).
s.trackGuids = req.trackGuids;
s.channelCount = req.channelCount;
// Resolved sample rate: the request's pinned rate, else PROJECT_SRATE read
// from the caller's project handle. PROJECT_SRATE can read 0 on a project that
// never explicitly pinned a rate — the value stays 0 (the Sample zero-value)
// rather than a bogus literal (the honest "unknown" both backends shared).
s.sampleRate = (req.sampleRate > 0)
? req.sampleRate
: static_cast<int>(GetSetProjectInfo(rateProj, "PROJECT_SRATE", 0.0, false));
s.captureTempo = Master_GetTempo(); // BPM at capture time (verified ~4651)
// Time signature at the capture's START time (L7 F1 stamp). TimeMap_GetTimeSigAtTime
// (verified reaper_plugin_functions.h:7130 — void(ReaProject*, double time,
// int* numOut, int* denomOut, double* tempoOut)) reads the meter effective at
// that project time, so a sample captured under 3/4 keeps a 3/4 read-out even
// if the project later switches to 4/4. `timeSigProj` is the CALLER's project
// pin — offline passes nullptr (the active project); realtime pins the record's
// own project (the T2-09 divergence, kept caller-visible as this argument).
// tempoOut is ignored — captureTempo already carries the master tempo. Leaves
// 0/0 (unstamped) if the API is somehow unavailable; the formatter renders a
// blank musical read-out.
{
int tsNum = 0, tsDenom = 0;
double tsTempo = 0.0;
TimeMap_GetTimeSigAtTime(timeSigProj, req.startSeconds, &tsNum, &tsDenom, &tsTempo);
s.captureTimeSigNum = tsNum;
s.captureTimeSigDenom = tsDenom;
}
// Content hash: WAV-aware FNV-1a over the finished file's fmt+data chunks so
// hashReferencedElsewhere can identify copies in other banks and suppress the
// last-reference confirm when another bank still holds the same file. Using
// hashWavContent (not the raw hashBytes) skips render-varying metadata chunks
// (bext origination timestamp, iXML, LIST/INFO, etc.) so two renders/records of
// identical audio collapse to the same hash. Best-effort: an unreadable file
// leaves contentHash empty — the safe, confirm-eliciting direction (bank_model
// treats "" as non-participating in dedup).
{
const std::vector<std::uint8_t> fileBytes = util::readFileBytes(absolutePath);
if (!fileBytes.empty()) {
s.contentHash = hashWavContent(fileBytes);
}
}
s.createdTimestamp = static_cast<std::int64_t>(std::time(nullptr));
}
CaptureResult OfflineRenderBackend::capture(const CaptureRequest& request) {
CaptureResult result;
@@ -340,9 +405,9 @@ CaptureResult OfflineRenderBackend::capture(const CaptureRequest& request) {
}();
// Compute the unique tag ONCE so the file stem and Sample.id carry the same
// timestamp. Calling makeUniqueTag() twice could yield different values if a
// second boundary crosses between the two calls (bug: id and filename diverge).
const std::string uniqueTag = makeUniqueTag();
// tag. Calling makeUniqueTag() twice would yield different values (the counter
// advances per callbug: id and filename diverge).
const std::string uniqueTag = makeUniqueTag("");
const BankPaths paths =
deriveBankPaths(projectDir, request.baseName, uniqueTag);
@@ -477,50 +542,16 @@ CaptureResult OfflineRenderBackend::capture(const CaptureRequest& request) {
// Seconds are the authoritative source for the render. Do NOT add DAW-
// unverifiable PPQ resolution here — it requires a live REAPER to validate.
s.wetDry = request.wetDry;
// Track GUIDs for track-scoped captures (empty for master/items/razor). The
// caller resolved the selection to canonical GUID strings; we record them so a
// "re-capture from source" (M10) knows which tracks the sample came from.
s.trackGuids = request.trackGuids;
s.channelCount = request.channelCount;
// Store the resolved sample rate only when it is known (> 0). If the project
// never pinned a rate (PROJECT_SRATE read 0), we did not force RENDER_SRATE
// either, so the render ran at REAPER's project default — an unknown value from
// this code's perspective. Leave sampleRate at 0 (the Sample zero-value) rather
// than store a bogus literal; M6/M7 can fill it in by probing the rendered file.
s.sampleRate = effectiveSampleRate; // 0 when project rate was unknown
s.lengthSeconds = request.endSeconds - request.startSeconds;
s.captureTempo = Master_GetTempo(); // BPM at capture time (verified ~4651)
// Time signature at the capture's START time (L7 F1 stamp). TimeMap_GetTimeSigAtTime
// (verified reaper_plugin_functions.h:7130 — void(ReaProject*, double time,
// int* numOut, int* denomOut, double* tempoOut)) reads the meter effective at that
// project time, so a sample captured under 3/4 keeps a 3/4 read-out even if the
// project later switches to 4/4. proj=nullptr => the active project (matches the
// Master_GetTempo() call above, which is also active-project). The tempoOut is
// ignored — captureTempo already carries the master tempo. Leaves 0/0 (unstamped)
// if the API is somehow unavailable; the formatter renders a blank musical read-out.
{
int tsNum = 0, tsDenom = 0;
double tsTempo = 0.0;
TimeMap_GetTimeSigAtTime(nullptr, request.startSeconds, &tsNum, &tsDenom, &tsTempo);
s.captureTimeSigNum = tsNum;
s.captureTimeSigDenom = tsDenom;
}
s.tier = Tier::Scratch; // captures land in scratch by default
// Content hash: WAV-aware FNV-1a over the rendered file's fmt+data chunks so
// hashReferencedElsewhere can identify copies in other banks and suppress the
// last-reference confirm when another bank still holds the same file. Using
// hashWavContent (not the raw hashBytes) skips render-varying metadata chunks
// (bext origination timestamp, iXML, LIST/INFO, etc.) so two renders of identical
// audio collapse to the same hash. Best-effort: an unreadable file leaves
// contentHash empty — the safe, confirm-eliciting direction (bank_model treats
// "" as non-participating in dedup, which is the existing fallback semantics).
{
const std::vector<std::uint8_t> fileBytes = readFileBytes(expectedPath);
if (!fileBytes.empty()) {
s.contentHash = hashWavContent(fileBytes);
}
}
s.createdTimestamp = static_cast<std::int64_t>(std::time(nullptr));
s.tier = model::Tier::Scratch; // captures land in scratch by default
// The SHARED finished-capture stamp (T2-09 dedupe): trackGuids + channelCount
// (request echo), resolved sampleRate (request rate else PROJECT_SRATE(proj) —
// 0 stays 0 when the project never pinned a rate; we did not force RENDER_SRATE
// either, so the render ran at REAPER's default), captureTempo, the capture-
// start time signature (timeSigProj = nullptr => the active project matching
// the Master_GetTempo read, which is also active-project), the WAV-aware
// contentHash of the rendered file, and createdTimestamp.
stampCaptureSample(s, request, proj, /*timeSigProj=*/nullptr, expectedPath);
// Phase S seam fields (rootNote / loop) left empty (D-B). An offline render of a
// master mix / track / time-selection is not a single played note, so no root
// note is derivable here — we do NOT guess one. Loop points are set later by an
@@ -537,4 +568,4 @@ CaptureResult OfflineRenderBackend::capture(const CaptureRequest& request) {
// guard's dtor restores every RENDER_* setting here.
}
} // namespace reasampler
} // namespace reasampler::capture