T2: realtime capture tail trimming
Auto/Manual/Off tail handling for realtime track-tap capture. Auto scans recorded PCM backward for last frame above -72 dB and truncates the wav header-aware (8 s cap); Manual pads a fixed tail; Off is byte-identical. wav_trim rejects WAVE_FORMAT_EXTENSIBLE with non-float SubFormat GUID.
This commit is contained in:
+182
-5
@@ -71,13 +71,18 @@
|
||||
|
||||
#include <chrono>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <ctime>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "capture_paths.h"
|
||||
#include "peaks.h" // lastFrameAboveThreshold, AudioSample
|
||||
#include "realtime_record.h"
|
||||
#include "render_settings.h" // autoTrimEndRatio, realtimeRecordWindowEnd
|
||||
#include "wav_trim.h" // parseWavLayout, extractFloatFrames, planWavTruncate
|
||||
|
||||
#define REAPERAPI_MINIMAL
|
||||
#define REAPERAPI_WANT_EnumProjects
|
||||
@@ -178,6 +183,12 @@ public:
|
||||
BankPaths paths_;
|
||||
std::string uniqueTag_;
|
||||
|
||||
// The RECORDED window end in project seconds (>= request_.endSeconds). For a tail
|
||||
// mode the transport runs PAST the range end (Auto: +8 s cap; Manual: +the set
|
||||
// length), so this — not request_.endSeconds — is the end the completion state
|
||||
// machine waits for. Equals request_.endSeconds for TailMode::None (exact bounds).
|
||||
double recordWindowEnd_ = 0.0;
|
||||
|
||||
// The transient sink. The sends we create (from each selected source track INTO
|
||||
// temp_) live on those source tracks pointing AT temp_, and are removed automatically
|
||||
// when temp_ is deleted — REAPER cannot leave a send dangling to a deleted
|
||||
@@ -311,6 +322,128 @@ private:
|
||||
|
||||
namespace {
|
||||
|
||||
// Reads the whole file into a byte buffer. Empty vector on any I/O failure — the
|
||||
// caller treats an unreadable file as "skip the trim" (keep the untrimmed window),
|
||||
// never as a corruption of the recorded audio.
|
||||
std::vector<std::uint8_t> readAllBytes(const std::string& path) {
|
||||
std::ifstream f(path, std::ios::binary | std::ios::ate);
|
||||
if (!f) return {};
|
||||
const std::streamoff size = f.tellg();
|
||||
if (size <= 0) return {};
|
||||
std::vector<std::uint8_t> bytes(static_cast<std::size_t>(size));
|
||||
f.seekg(0);
|
||||
f.read(reinterpret_cast<char*>(bytes.data()), size);
|
||||
if (!f) return {};
|
||||
return bytes;
|
||||
}
|
||||
|
||||
// Patches a little-endian uint32 into a byte buffer at `off` (the header size fields).
|
||||
void writeU32LE(std::vector<std::uint8_t>& bytes, std::size_t off, std::uint32_t v) {
|
||||
bytes[off + 0] = static_cast<std::uint8_t>(v & 0xFF);
|
||||
bytes[off + 1] = static_cast<std::uint8_t>((v >> 8) & 0xFF);
|
||||
bytes[off + 2] = static_cast<std::uint8_t>((v >> 16) & 0xFF);
|
||||
bytes[off + 3] = static_cast<std::uint8_t>((v >> 24) & 0xFF);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// §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 = readAllBytes(path);
|
||||
if (bytes.empty()) return kNoTrim;
|
||||
|
||||
const reasampler::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<reasampler::AudioSample> tailPcm =
|
||||
extractFloatFrames(bytes, layout, rangeEndFrame, tailFrames);
|
||||
if (tailPcm.empty()) return kNoTrim;
|
||||
|
||||
const float threshold = static_cast<float>(reasampler::autoTrimEndRatio());
|
||||
const std::size_t lastAbove = reasampler::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 == reasampler::kNoFrameAboveThreshold) {
|
||||
keptFrames = rangeEndFrame;
|
||||
} else {
|
||||
keptFrames = rangeEndFrame + (lastAbove + 1);
|
||||
}
|
||||
if (keptFrames >= totalFrames) return kNoTrim; // full window kept -> no truncate
|
||||
|
||||
const reasampler::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, 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_trim re-parse test).
|
||||
writeU32LE(bytes, plan.dataSizeFieldOffset, plan.newDataSize);
|
||||
writeU32LE(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);
|
||||
}
|
||||
|
||||
// Builds a CaptureResult for a finalized recording: discover the recorded file,
|
||||
// move it into the bank, populate the Sample via the pure mapping. Returns Ok +
|
||||
// Sample on success, or a RenderFailed result. Does NOT restore — the caller
|
||||
@@ -347,6 +480,19 @@ CaptureResult finalizeRecording(RealtimeCaptureState& st) {
|
||||
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 (st.request_.tailMode == TailMode::Auto) {
|
||||
trimmedLenSeconds = trimAutoTailInPlace(destPath,
|
||||
st.request_.startSeconds,
|
||||
st.request_.endSeconds);
|
||||
}
|
||||
|
||||
RecordedCapture cap;
|
||||
cap.relativePath = st.paths_.relativePath;
|
||||
cap.uniqueTag = st.uniqueTag_;
|
||||
@@ -365,9 +511,25 @@ CaptureResult finalizeRecording(RealtimeCaptureState& st) {
|
||||
|
||||
result.status = CaptureStatus::Ok;
|
||||
result.sample = sampleFromRecordedCapture(cap);
|
||||
|
||||
// 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 =
|
||||
st.recordWindowEnd_ - st.request_.startSeconds;
|
||||
}
|
||||
|
||||
result.message = "Realtime-captured [" +
|
||||
std::to_string(st.request_.startSeconds) + "s, " +
|
||||
std::to_string(st.request_.endSeconds) + "s] -> " +
|
||||
std::to_string(st.request_.endSeconds) + "s] (recorded " +
|
||||
std::to_string(result.sample.lengthSeconds) + "s) -> " +
|
||||
st.paths_.relativePath;
|
||||
return result;
|
||||
}
|
||||
@@ -448,6 +610,14 @@ RealtimeRecordBackend::begin(const CaptureRequest& request,
|
||||
st->uniqueTag_ = makeUniqueTag();
|
||||
st->paths_ = deriveBankPaths(projectDir, request.baseName, st->uniqueTag_);
|
||||
|
||||
// The recorded window end: extended past the range end for a tail mode (Auto/Manual),
|
||||
// exact for None. This — not request.endSeconds — is what the completion machine
|
||||
// waits for; the extra window past the range end is trimmed later (Auto) or kept
|
||||
// (Manual). Pure mapping (render_settings), shared caps with the offline tail.
|
||||
st->recordWindowEnd_ = realtimeRecordWindowEnd(request.tailMode,
|
||||
request.endSeconds,
|
||||
request.tailMs);
|
||||
|
||||
// DELIBERATE: the transient temp-track / arm / send / transport mutations are NOT
|
||||
// wrapped in an Undo_BeginBlock/Undo_EndBlock — divergence from the insert/view
|
||||
// shells is intentional. This backend fully restores its own state across every
|
||||
@@ -514,9 +684,12 @@ RealtimeRecordBackend::begin(const CaptureRequest& request,
|
||||
SetMediaTrackInfo_Value(st->temp_, "I_RECARM", 1.0); // arm ONLY the sink
|
||||
SetMediaTrackInfo_Value(st->temp_, "I_RECMON", 0.0); // no input monitoring
|
||||
|
||||
// Record range: time selection over [start,end], play cursor at start. Both were
|
||||
// snapshotted and will be restored by restore().
|
||||
double rs = request.startSeconds, re = request.endSeconds;
|
||||
// Record range: time selection over [start, recordWindowEnd], play cursor at start.
|
||||
// recordWindowEnd extends past the request's range end for a tail mode so the
|
||||
// transport captures the decaying tail; it equals the range end for None (exact
|
||||
// bounds). Both cursor + time selection were snapshotted and are restored by
|
||||
// restore().
|
||||
double rs = request.startSeconds, re = st->recordWindowEnd_;
|
||||
GetSet_LoopTimeRange(true, false, &rs, &re, false);
|
||||
SetEditCurPos(request.startSeconds, false, false);
|
||||
|
||||
@@ -567,9 +740,13 @@ RealtimeTickResult RealtimeRecordBackend::tick(RealtimeCaptureState& state) {
|
||||
state.lastFileSize_ = sz;
|
||||
}
|
||||
|
||||
// Wait for the transport to reach the RECORDED window end (extended past the
|
||||
// range end for a tail mode), not the request's range end — the extra tail window
|
||||
// is part of the record. The record safety ceiling scales with it (window - start
|
||||
// + margin) inside the pure machine.
|
||||
state.phase_ = advanceRecordPhase(state.phase_, inputs,
|
||||
state.request_.startSeconds,
|
||||
state.request_.endSeconds);
|
||||
state.recordWindowEnd_);
|
||||
|
||||
// On the Recording -> Finalizing edge, stop OUR project's transport ONCE so REAPER
|
||||
// begins closing/flushing the recorded take. Project-scoped (OnStopButtonEx(proj_))
|
||||
|
||||
Reference in New Issue
Block a user