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:
+19
-1
@@ -120,6 +120,20 @@ add_library(realtime_record STATIC src/realtime_record.cpp)
|
|||||||
target_include_directories(realtime_record PUBLIC src)
|
target_include_directories(realtime_record PUBLIC src)
|
||||||
target_link_libraries(realtime_record PUBLIC bank_model)
|
target_link_libraries(realtime_record PUBLIC bank_model)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 2h) Pure wav_trim library — NO REAPER, NO SWELL. The realtime tail's (T2) PCM
|
||||||
|
# decay-scan trim needs to TRUNCATE the recorded 32-bit-float WAV at a frame
|
||||||
|
# boundary without corrupting the RIFF container. This module holds the fiddly,
|
||||||
|
# easy-to-get-wrong part unit-tested outside the DAW: parse the WAV geometry
|
||||||
|
# (fmt/data chunk walk + 32-bit-float verification), extract the tail-region
|
||||||
|
# floats to scan, and compute the truncate plan (kept byte length + the two
|
||||||
|
# patched RIFF/data size fields). The file read/write/truncate I/O stays in the
|
||||||
|
# realtime shell. Depends on peaks for the AudioSample float alias.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
add_library(wav_trim STATIC src/wav_trim.cpp)
|
||||||
|
target_include_directories(wav_trim PUBLIC src)
|
||||||
|
target_link_libraries(wav_trim PUBLIC peaks)
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 3) Standalone tests for the pure modules (run without launching REAPER).
|
# 3) Standalone tests for the pure modules (run without launching REAPER).
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -168,6 +182,10 @@ add_executable(realtime_record_tests tests/test_realtime_record.cpp)
|
|||||||
target_link_libraries(realtime_record_tests PRIVATE realtime_record)
|
target_link_libraries(realtime_record_tests PRIVATE realtime_record)
|
||||||
add_test(NAME realtime_record_tests COMMAND realtime_record_tests)
|
add_test(NAME realtime_record_tests COMMAND realtime_record_tests)
|
||||||
|
|
||||||
|
add_executable(wav_trim_tests tests/test_wav_trim.cpp)
|
||||||
|
target_link_libraries(wav_trim_tests PRIVATE wav_trim)
|
||||||
|
add_test(NAME wav_trim_tests COMMAND wav_trim_tests)
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 4) The REAPER extension — a loadable module (dlopen'd by REAPER, not linked).
|
# 4) The REAPER extension — a loadable module (dlopen'd by REAPER, not linked).
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -199,7 +217,7 @@ add_library(reaper_reasampler MODULE
|
|||||||
src/track_guid.cpp
|
src/track_guid.cpp
|
||||||
src/actions.cpp
|
src/actions.cpp
|
||||||
)
|
)
|
||||||
target_link_libraries(reaper_reasampler PRIVATE bank_model capture_paths peaks bank_grid mode_switch view_mode_model insert_plan render_settings tail_control realtime_record)
|
target_link_libraries(reaper_reasampler PRIVATE bank_model capture_paths peaks bank_grid mode_switch view_mode_model insert_plan render_settings tail_control realtime_record wav_trim)
|
||||||
target_include_directories(reaper_reasampler PRIVATE ${SDK_INC} ${WDL_INC})
|
target_include_directories(reaper_reasampler PRIVATE ${SDK_INC} ${WDL_INC})
|
||||||
set_target_properties(reaper_reasampler PROPERTIES PREFIX "" OUTPUT_NAME "reaper_reasampler")
|
set_target_properties(reaper_reasampler PROPERTIES PREFIX "" OUTPUT_NAME "reaper_reasampler")
|
||||||
|
|
||||||
|
|||||||
+182
-5
@@ -71,13 +71,18 @@
|
|||||||
|
|
||||||
#include <chrono>
|
#include <chrono>
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
|
#include <cstring>
|
||||||
#include <ctime>
|
#include <ctime>
|
||||||
#include <filesystem>
|
#include <filesystem>
|
||||||
|
#include <fstream>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
#include "capture_paths.h"
|
#include "capture_paths.h"
|
||||||
|
#include "peaks.h" // lastFrameAboveThreshold, AudioSample
|
||||||
#include "realtime_record.h"
|
#include "realtime_record.h"
|
||||||
|
#include "render_settings.h" // autoTrimEndRatio, realtimeRecordWindowEnd
|
||||||
|
#include "wav_trim.h" // parseWavLayout, extractFloatFrames, planWavTruncate
|
||||||
|
|
||||||
#define REAPERAPI_MINIMAL
|
#define REAPERAPI_MINIMAL
|
||||||
#define REAPERAPI_WANT_EnumProjects
|
#define REAPERAPI_WANT_EnumProjects
|
||||||
@@ -178,6 +183,12 @@ public:
|
|||||||
BankPaths paths_;
|
BankPaths paths_;
|
||||||
std::string uniqueTag_;
|
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
|
// 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
|
// 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
|
// when temp_ is deleted — REAPER cannot leave a send dangling to a deleted
|
||||||
@@ -311,6 +322,128 @@ private:
|
|||||||
|
|
||||||
namespace {
|
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,
|
// 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 +
|
// 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
|
// 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
|
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;
|
RecordedCapture cap;
|
||||||
cap.relativePath = st.paths_.relativePath;
|
cap.relativePath = st.paths_.relativePath;
|
||||||
cap.uniqueTag = st.uniqueTag_;
|
cap.uniqueTag = st.uniqueTag_;
|
||||||
@@ -365,9 +511,25 @@ CaptureResult finalizeRecording(RealtimeCaptureState& st) {
|
|||||||
|
|
||||||
result.status = CaptureStatus::Ok;
|
result.status = CaptureStatus::Ok;
|
||||||
result.sample = sampleFromRecordedCapture(cap);
|
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 [" +
|
result.message = "Realtime-captured [" +
|
||||||
std::to_string(st.request_.startSeconds) + "s, " +
|
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;
|
st.paths_.relativePath;
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
@@ -448,6 +610,14 @@ RealtimeRecordBackend::begin(const CaptureRequest& request,
|
|||||||
st->uniqueTag_ = makeUniqueTag();
|
st->uniqueTag_ = makeUniqueTag();
|
||||||
st->paths_ = deriveBankPaths(projectDir, request.baseName, st->uniqueTag_);
|
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
|
// DELIBERATE: the transient temp-track / arm / send / transport mutations are NOT
|
||||||
// wrapped in an Undo_BeginBlock/Undo_EndBlock — divergence from the insert/view
|
// wrapped in an Undo_BeginBlock/Undo_EndBlock — divergence from the insert/view
|
||||||
// shells is intentional. This backend fully restores its own state across every
|
// 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_RECARM", 1.0); // arm ONLY the sink
|
||||||
SetMediaTrackInfo_Value(st->temp_, "I_RECMON", 0.0); // no input monitoring
|
SetMediaTrackInfo_Value(st->temp_, "I_RECMON", 0.0); // no input monitoring
|
||||||
|
|
||||||
// Record range: time selection over [start,end], play cursor at start. Both were
|
// Record range: time selection over [start, recordWindowEnd], play cursor at start.
|
||||||
// snapshotted and will be restored by restore().
|
// recordWindowEnd extends past the request's range end for a tail mode so the
|
||||||
double rs = request.startSeconds, re = request.endSeconds;
|
// 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);
|
GetSet_LoopTimeRange(true, false, &rs, &re, false);
|
||||||
SetEditCurPos(request.startSeconds, false, false);
|
SetEditCurPos(request.startSeconds, false, false);
|
||||||
|
|
||||||
@@ -567,9 +740,13 @@ RealtimeTickResult RealtimeRecordBackend::tick(RealtimeCaptureState& state) {
|
|||||||
state.lastFileSize_ = sz;
|
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.phase_ = advanceRecordPhase(state.phase_, inputs,
|
||||||
state.request_.startSeconds,
|
state.request_.startSeconds,
|
||||||
state.request_.endSeconds);
|
state.recordWindowEnd_);
|
||||||
|
|
||||||
// On the Recording -> Finalizing edge, stop OUR project's transport ONCE so REAPER
|
// On the Recording -> Finalizing edge, stop OUR project's transport ONCE so REAPER
|
||||||
// begins closing/flushing the recorded take. Project-scoped (OnStopButtonEx(proj_))
|
// begins closing/flushing the recorded take. Project-scoped (OnStopButtonEx(proj_))
|
||||||
|
|||||||
+18
-4
@@ -596,13 +596,20 @@ static void RunCaptureRealtimeTrack()
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The tail mode is the SAME panel setting the offline capture actions read (the
|
||||||
|
// docked bank panel's toggle). Realtime honors it via a parallel path: the backend
|
||||||
|
// records a generous window past the range end, then trims by PCM decay-scan (T2 /
|
||||||
|
// capture-tail.md §The realtime path) — it does NOT drive RENDER_*. Default None
|
||||||
|
// keeps realtime exact-bounds / byte-identical to today.
|
||||||
|
const reasampler::TailSetting tail = reasampler::bankPanelTailSetting();
|
||||||
|
|
||||||
reasampler::CaptureRequest req;
|
reasampler::CaptureRequest req;
|
||||||
req.sourceMode = reasampler::SourceMode::SelectedTracks; // realtime track scope
|
req.sourceMode = reasampler::SourceMode::SelectedTracks; // realtime track scope
|
||||||
req.startSeconds = src.startSeconds; // exact bounds — no rounding
|
req.startSeconds = src.startSeconds; // exact bounds — no rounding
|
||||||
req.endSeconds = src.endSeconds;
|
req.endSeconds = src.endSeconds;
|
||||||
req.wetDry = 1.0; // fully wet (post-fader tap)
|
req.wetDry = 1.0; // fully wet (post-fader tap)
|
||||||
req.tailMode = reasampler::TailMode::None; // realtime tail is T2; exact bounds here
|
req.tailMode = tail.mode; // None / Auto / Manual, from the panel toggle
|
||||||
req.tailMs = 0.0;
|
req.tailMs = tail.manualMs; // Manual-only (pre-clamped); ignored for None/Auto
|
||||||
req.sampleRate = 0; // follow project rate
|
req.sampleRate = 0; // follow project rate
|
||||||
req.channelCount = 2;
|
req.channelCount = 2;
|
||||||
req.bitDepth = reasampler::WavBitDepth::Float32;
|
req.bitDepth = reasampler::WavBitDepth::Float32;
|
||||||
@@ -623,8 +630,15 @@ static void RunCaptureRealtimeTrack()
|
|||||||
// completion across ticks (UI stays responsive).
|
// completion across ticks (UI stays responsive).
|
||||||
g_rtCaptureProject = EnumProjects(-1, nullptr, 0);
|
g_rtCaptureProject = EnumProjects(-1, nullptr, 0);
|
||||||
g_rtCapture = std::move(st);
|
g_rtCapture = std::move(st);
|
||||||
ShowConsoleMsg("ReaSampler: realtime capture started — recording in the "
|
// With a tail mode the recorded window runs PAST the range end (Auto: +8 s then
|
||||||
"background; the bank updates when it reaches the range end.\n");
|
// decay-trim; Manual: +the set length), so the completion note names the window,
|
||||||
|
// not just the range end.
|
||||||
|
const char* doneWhen =
|
||||||
|
(tail.mode == reasampler::TailMode::None)
|
||||||
|
? "the bank updates when it reaches the range end."
|
||||||
|
: "the bank updates after the extra tail window (past the range end).";
|
||||||
|
ShowConsoleMsg((std::string("ReaSampler: realtime capture started — recording in "
|
||||||
|
"the background; ") + doneWhen + "\n").c_str());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cancels the in-flight realtime capture on demand (bindable action). Force-terminates
|
// Cancels the in-flight realtime capture on demand (bindable action). Force-terminates
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <climits>
|
#include <climits>
|
||||||
|
#include <cmath>
|
||||||
|
|
||||||
// peaks implementation.
|
// peaks implementation.
|
||||||
//
|
//
|
||||||
@@ -63,4 +64,31 @@ Envelope computeEnvelope(const std::vector<AudioSample>& interleaved,
|
|||||||
return envelope;
|
return envelope;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
std::size_t lastFrameAboveThreshold(const std::vector<AudioSample>& interleaved,
|
||||||
|
std::size_t channelCount,
|
||||||
|
std::size_t frameCount,
|
||||||
|
AudioSample linearThreshold) {
|
||||||
|
if (channelCount == 0) return kNoFrameAboveThreshold;
|
||||||
|
|
||||||
|
// Clamp to what the buffer actually holds — a caller frameCount that overstates
|
||||||
|
// the buffer must never read past the end (mirror of computeEnvelope's guard).
|
||||||
|
const std::size_t availableFrames = interleaved.size() / channelCount;
|
||||||
|
const std::size_t frames = std::min(frameCount, availableFrames);
|
||||||
|
if (frames == 0) return kNoFrameAboveThreshold;
|
||||||
|
|
||||||
|
// Scan backward: the first frame (from the end) whose loudest channel exceeds the
|
||||||
|
// threshold is the last audible frame. `f` runs frames..1 so `f-1` never wraps.
|
||||||
|
for (std::size_t f = frames; f > 0; --f) {
|
||||||
|
const std::size_t frame = f - 1;
|
||||||
|
const std::size_t base = frame * channelCount;
|
||||||
|
AudioSample peak = 0.0f;
|
||||||
|
for (std::size_t c = 0; c < channelCount; ++c) {
|
||||||
|
const AudioSample a = std::fabs(interleaved[base + c]);
|
||||||
|
peak = std::max(peak, a);
|
||||||
|
}
|
||||||
|
if (peak > linearThreshold) return frame;
|
||||||
|
}
|
||||||
|
return kNoFrameAboveThreshold;
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace reasampler
|
} // namespace reasampler
|
||||||
|
|||||||
+38
@@ -66,4 +66,42 @@ Envelope computeEnvelope(const std::vector<AudioSample>& interleaved,
|
|||||||
std::size_t frameCount,
|
std::size_t frameCount,
|
||||||
std::size_t binCount);
|
std::size_t binCount);
|
||||||
|
|
||||||
|
// Sentinel returned by lastFrameAboveThreshold when NO frame in the scanned range
|
||||||
|
// peaks above the threshold (pure silence at that level). SIZE_MAX is unambiguous:
|
||||||
|
// no valid frame index can equal it (a real index is < frameCount <= SIZE_MAX for
|
||||||
|
// any allocatable buffer), so the caller tests `== kNoFrameAboveThreshold` cleanly.
|
||||||
|
inline constexpr std::size_t kNoFrameAboveThreshold =
|
||||||
|
static_cast<std::size_t>(-1);
|
||||||
|
|
||||||
|
// Scans interleaved PCM BACKWARD for the last frame whose per-frame peak (the max
|
||||||
|
// absolute value across all channels of that frame — NO stereo fold, just the
|
||||||
|
// loudest channel that frame) exceeds `linearThreshold`, returning that frame index.
|
||||||
|
// Returns kNoFrameAboveThreshold if no frame exceeds it (or on degenerate input).
|
||||||
|
//
|
||||||
|
// This is the boundary primitive behind the realtime tail's decay-scan trim
|
||||||
|
// (docs/product/capture-tail.md §The realtime path): the recorded tail window is
|
||||||
|
// scanned back from the end for the last frame still above -72 dB, and the file is
|
||||||
|
// truncated one frame past it. Deliberately a separate primitive from
|
||||||
|
// computeEnvelope — that answers "the min/max envelope over bins" (a thumbnail),
|
||||||
|
// this answers "the last frame above a level" (a boundary). Bending the bin-oriented
|
||||||
|
// envelope to a frame-exact boundary question is a worse fit (spec §option a).
|
||||||
|
//
|
||||||
|
// interleaved frame-interleaved samples: [f0c0, f0c1, ..., f1c0, f1c1, ...].
|
||||||
|
// Must hold >= frameCount * channelCount; extra is ignored, and a
|
||||||
|
// short buffer is clamped to what it actually holds (no OOB read).
|
||||||
|
// channelCount channels per frame (the stride). The per-frame test is the max
|
||||||
|
// |sample| over these channels — the frame is "above" if its
|
||||||
|
// loudest channel is above the threshold.
|
||||||
|
// frameCount frames to consider (the scan starts at the last of these).
|
||||||
|
// linearThreshold the comparison level as a LINEAR amplitude ratio (e.g. the
|
||||||
|
// -72 dB ratio from render_settings::autoTrimEndRatio), NOT dB.
|
||||||
|
// A frame counts as above when its peak is STRICTLY > this.
|
||||||
|
//
|
||||||
|
// Pure, stdlib-only, unit-tested (a synthetic decaying ramp, silence, all-above,
|
||||||
|
// and degenerate inputs) so the trim boundary math is locked outside the DAW.
|
||||||
|
std::size_t lastFrameAboveThreshold(const std::vector<AudioSample>& interleaved,
|
||||||
|
std::size_t channelCount,
|
||||||
|
std::size_t frameCount,
|
||||||
|
AudioSample linearThreshold);
|
||||||
|
|
||||||
} // namespace reasampler
|
} // namespace reasampler
|
||||||
|
|||||||
@@ -56,6 +56,24 @@ TailRenderSettings tailRenderSettingsFor(TailMode mode, double manualTailMs) {
|
|||||||
return t;
|
return t;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
double realtimeRecordWindowEnd(TailMode mode, double rangeEndSeconds,
|
||||||
|
double manualTailMs) {
|
||||||
|
switch (mode) {
|
||||||
|
case TailMode::None:
|
||||||
|
// Exact — no extra recording (byte-identical to today's realtime capture).
|
||||||
|
return rangeEndSeconds;
|
||||||
|
case TailMode::Auto:
|
||||||
|
// The 8 s runaway cap past the range end; the decay-trim shortens it later.
|
||||||
|
return rangeEndSeconds + kMaxTailSeconds;
|
||||||
|
case TailMode::Manual:
|
||||||
|
// Fixed window: range + the set length, clamped to the 8 s cap (the same
|
||||||
|
// runaway guard the offline Manual path applies). Negative floors to 0.
|
||||||
|
return rangeEndSeconds + std::clamp(manualTailMs, 0.0, kMaxTailMs) / 1000.0;
|
||||||
|
}
|
||||||
|
// Unreachable for a valid enum; fail closed to exact bounds (never a stray tail).
|
||||||
|
return rangeEndSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
RenderSettingsChoice renderSettingsFor(SourceMode mode, double /*wetDry*/) {
|
RenderSettingsChoice renderSettingsFor(SourceMode mode, double /*wetDry*/) {
|
||||||
// `wetDry` is accepted so CaptureRequest.wetDry remains the seam for future
|
// `wetDry` is accepted so CaptureRequest.wetDry remains the seam for future
|
||||||
// dry work (M10 null test), but it does not affect this mapping. FX scoping is
|
// dry work (M10 null test), but it does not affect this mapping. FX scoping is
|
||||||
|
|||||||
@@ -114,6 +114,20 @@ struct TailRenderSettings {
|
|||||||
// the Auto default or an explicit request (spec §Manual override). Pure + tested.
|
// the Auto default or an explicit request (spec §Manual override). Pure + tested.
|
||||||
TailRenderSettings tailRenderSettingsFor(TailMode mode, double manualTailMs);
|
TailRenderSettings tailRenderSettingsFor(TailMode mode, double manualTailMs);
|
||||||
|
|
||||||
|
// The REALTIME record-window end (in project seconds) a tail mode records to, given
|
||||||
|
// the request's exact range end (docs/product/capture-tail.md §The realtime path).
|
||||||
|
// Realtime does NOT drive RENDER_*; it records a generous window and trims later, so
|
||||||
|
// the window end is where the transport actually stops:
|
||||||
|
// None -> rangeEndSeconds (exact — no extra recording).
|
||||||
|
// Auto -> rangeEndSeconds + kMaxTailSeconds (the 8 s runaway cap; trimmed later).
|
||||||
|
// Manual -> rangeEndSeconds + clamp(manualTailMs, kMaxTailMs)/1000 (fixed, no trim).
|
||||||
|
// `manualTailMs` is used ONLY for Manual. Pure so the mode->window arithmetic (and
|
||||||
|
// the Manual clamp) is unit-tested outside the DAW; the backend applies the returned
|
||||||
|
// end to the record time selection. Shared -72 dB / 8 s constants are the same ones
|
||||||
|
// the offline tail uses (single source of truth).
|
||||||
|
double realtimeRecordWindowEnd(TailMode mode, double rangeEndSeconds,
|
||||||
|
double manualTailMs);
|
||||||
|
|
||||||
// The RENDER_SETTINGS value for a given source mode. `supported` is false only
|
// The RENDER_SETTINGS value for a given source mode. `supported` is false only
|
||||||
// for SourceMode::Realtime (that is the M8 backend, not offline render).
|
// for SourceMode::Realtime (that is the M8 backend, not offline render).
|
||||||
struct RenderSettingsChoice {
|
struct RenderSettingsChoice {
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
// wav_trim — pure implementation. See wav_trim.h. NO REAPER / SWELL / vendor.
|
||||||
|
|
||||||
|
#include "wav_trim.h"
|
||||||
|
|
||||||
|
#include <cstring> // std::memcpy, std::memcmp
|
||||||
|
|
||||||
|
namespace reasampler {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
// Little-endian readers. Bounds are checked by the caller before each read; these
|
||||||
|
// assume `off + N <= bytes.size()`. memcpy avoids alignment/aliasing UB.
|
||||||
|
std::uint16_t readU16LE(const std::vector<std::uint8_t>& b, std::size_t off) {
|
||||||
|
return static_cast<std::uint16_t>(b[off] | (b[off + 1] << 8));
|
||||||
|
}
|
||||||
|
std::uint32_t readU32LE(const std::vector<std::uint8_t>& b, std::size_t off) {
|
||||||
|
return static_cast<std::uint32_t>(b[off]) |
|
||||||
|
(static_cast<std::uint32_t>(b[off + 1]) << 8) |
|
||||||
|
(static_cast<std::uint32_t>(b[off + 2]) << 16) |
|
||||||
|
(static_cast<std::uint32_t>(b[off + 3]) << 24);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool tagEquals(const std::vector<std::uint8_t>& b, std::size_t off, const char* tag) {
|
||||||
|
return off + 4 <= b.size() && std::memcmp(b.data() + off, tag, 4) == 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// WAVE format tags we accept as 32-bit float (see wav_trim.h FORMAT ASSUMPTION).
|
||||||
|
constexpr std::uint16_t kWaveFormatIeeeFloat = 0x0003;
|
||||||
|
constexpr std::uint16_t kWaveFormatExtensible = 0xFFFE;
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
WavLayout parseWavLayout(const std::vector<std::uint8_t>& bytes) {
|
||||||
|
WavLayout out;
|
||||||
|
|
||||||
|
// Minimum viable RIFF/WAVE: "RIFF"(4) size(4) "WAVE"(4) = 12 bytes.
|
||||||
|
if (bytes.size() < 12) return out;
|
||||||
|
if (!tagEquals(bytes, 0, "RIFF")) return out;
|
||||||
|
if (!tagEquals(bytes, 8, "WAVE")) return out;
|
||||||
|
|
||||||
|
bool haveFmt = false;
|
||||||
|
std::uint16_t fmtTag = 0, channels = 0, bitsPerSample = 0;
|
||||||
|
std::uint32_t sampleRate = 0;
|
||||||
|
std::uint16_t extensibleSubFormatTag = 0; // set only when fmtTag == kWaveFormatExtensible
|
||||||
|
|
||||||
|
// Walk the sub-chunks after "WAVE" (offset 12). Each is: id(4) size(4) body(size),
|
||||||
|
// body padded to an even byte count (RIFF word alignment). Stop cleanly if a
|
||||||
|
// header would run past the buffer — a malformed/truncated file is "invalid",
|
||||||
|
// never an OOB read.
|
||||||
|
std::size_t pos = 12;
|
||||||
|
while (pos + 8 <= bytes.size()) {
|
||||||
|
const std::size_t bodyOffset = pos + 8;
|
||||||
|
const std::uint32_t bodySize = readU32LE(bytes, pos + 4);
|
||||||
|
|
||||||
|
if (tagEquals(bytes, pos, "fmt ")) {
|
||||||
|
// fmt body: at least 16 bytes (PCM/float common fields).
|
||||||
|
if (bodyOffset + 16 > bytes.size() || bodySize < 16) return out;
|
||||||
|
fmtTag = readU16LE(bytes, bodyOffset + 0);
|
||||||
|
channels = readU16LE(bytes, bodyOffset + 2);
|
||||||
|
sampleRate = readU32LE(bytes, bodyOffset + 4);
|
||||||
|
bitsPerSample = readU16LE(bytes, bodyOffset + 14);
|
||||||
|
// For WAVE_FORMAT_EXTENSIBLE (0xFFFE), read the SubFormat GUID's leading
|
||||||
|
// 2-byte tag at body offset 24 to distinguish float (0x0003) from PCM
|
||||||
|
// integer (0x0001) and all other sub-formats. Body must be >= 40 bytes to
|
||||||
|
// reach GUID offset 24 + 16 bytes of GUID, and the full GUID must fit in
|
||||||
|
// the buffer; otherwise we leave extensibleSubFormatTag at 0 (rejected).
|
||||||
|
if (fmtTag == kWaveFormatExtensible) {
|
||||||
|
if (bodySize >= 40 && bodyOffset + 40 <= bytes.size()) {
|
||||||
|
extensibleSubFormatTag = readU16LE(bytes, bodyOffset + 24);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
haveFmt = true;
|
||||||
|
} else if (tagEquals(bytes, pos, "data")) {
|
||||||
|
// The data chunk: PCM starts at bodyOffset, declared length bodySize.
|
||||||
|
// Reject if it runs past the buffer (truncated / lying header).
|
||||||
|
if (bodyOffset + bodySize > bytes.size()) return out;
|
||||||
|
if (!haveFmt) return out; // data before fmt — not a WAV we parse
|
||||||
|
|
||||||
|
// Plain IEEE-float tag (0x0003): accept as-is.
|
||||||
|
// Extensible tag (0xFFFE): accept only when the SubFormat tag read from
|
||||||
|
// the GUID at body offset 24 is also 0x0003 (IEEE float). SubFormat tag
|
||||||
|
// 0x0001 (PCM integer) or anything else with bitsPerSample==32 is NOT
|
||||||
|
// float and must be rejected to prevent mis-decoding as float.
|
||||||
|
const bool floatTag = (fmtTag == kWaveFormatIeeeFloat) ||
|
||||||
|
(fmtTag == kWaveFormatExtensible &&
|
||||||
|
extensibleSubFormatTag == kWaveFormatIeeeFloat);
|
||||||
|
if (!floatTag || bitsPerSample != 32 || channels == 0) return out;
|
||||||
|
|
||||||
|
out.valid = true;
|
||||||
|
out.channelCount = channels;
|
||||||
|
out.sampleRate = sampleRate;
|
||||||
|
out.dataByteOffset = bodyOffset;
|
||||||
|
out.dataByteLength = bodySize;
|
||||||
|
out.riffSizeFieldOffset = 4;
|
||||||
|
out.dataSizeFieldOffset = pos + 4; // the `data` size field (LE uint32)
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Advance past this chunk's body, honoring RIFF even-byte padding. Guard the
|
||||||
|
// additions against size_t overflow (a hostile bodySize near SIZE_MAX).
|
||||||
|
std::size_t advance = bodySize;
|
||||||
|
if (advance & 1u) ++advance; // pad byte
|
||||||
|
if (advance > bytes.size() - bodyOffset) break; // would overrun -> stop
|
||||||
|
pos = bodyOffset + advance;
|
||||||
|
}
|
||||||
|
|
||||||
|
return out; // no data chunk found -> invalid
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<AudioSample> extractFloatFrames(const std::vector<std::uint8_t>& bytes,
|
||||||
|
const WavLayout& layout,
|
||||||
|
std::size_t startFrame,
|
||||||
|
std::size_t frameCount) {
|
||||||
|
std::vector<AudioSample> out;
|
||||||
|
if (!layout.valid) return out;
|
||||||
|
|
||||||
|
const std::size_t bytesPerFrame =
|
||||||
|
static_cast<std::size_t>(layout.channelCount) * 4u;
|
||||||
|
const std::size_t totalFrames = layout.frameCount();
|
||||||
|
if (startFrame >= totalFrames) return out;
|
||||||
|
|
||||||
|
// Clamp the requested span to the frames that actually exist.
|
||||||
|
const std::size_t avail = totalFrames - startFrame;
|
||||||
|
const std::size_t frames = (frameCount < avail) ? frameCount : avail;
|
||||||
|
if (frames == 0) return out;
|
||||||
|
|
||||||
|
const std::size_t firstByte =
|
||||||
|
layout.dataByteOffset + startFrame * bytesPerFrame;
|
||||||
|
out.resize(frames * layout.channelCount);
|
||||||
|
// memcpy each float (LE on target hosts — see header's byte-order note).
|
||||||
|
for (std::size_t i = 0; i < out.size(); ++i) {
|
||||||
|
float f = 0.0f;
|
||||||
|
std::memcpy(&f, bytes.data() + firstByte + i * 4u, 4u);
|
||||||
|
out[i] = f;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
WavTruncatePlan planWavTruncate(const WavLayout& layout, std::size_t keptFrames) {
|
||||||
|
WavTruncatePlan plan;
|
||||||
|
if (!layout.valid) return plan;
|
||||||
|
|
||||||
|
const std::size_t totalFrames = layout.frameCount();
|
||||||
|
if (keptFrames > totalFrames) return plan; // never grow
|
||||||
|
|
||||||
|
const std::size_t bytesPerFrame =
|
||||||
|
static_cast<std::size_t>(layout.channelCount) * 4u;
|
||||||
|
const std::size_t keptDataBytes = keptFrames * bytesPerFrame;
|
||||||
|
|
||||||
|
plan.valid = true;
|
||||||
|
plan.newFileByteLength = layout.dataByteOffset + keptDataBytes;
|
||||||
|
plan.dataSizeFieldOffset = layout.dataSizeFieldOffset;
|
||||||
|
plan.newDataSize = static_cast<std::uint32_t>(keptDataBytes);
|
||||||
|
plan.riffSizeFieldOffset = layout.riffSizeFieldOffset;
|
||||||
|
// RIFF size counts everything after the 8-byte "RIFF"+size prefix.
|
||||||
|
plan.newRiffSize = static_cast<std::uint32_t>(plan.newFileByteLength - 8);
|
||||||
|
return plan;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace reasampler
|
||||||
+101
@@ -0,0 +1,101 @@
|
|||||||
|
#pragma once
|
||||||
|
// wav_trim — pure parse + truncate-plan for the realtime tail's PCM decay-scan trim.
|
||||||
|
//
|
||||||
|
// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO
|
||||||
|
// vendor/ includes. Standard library only. Builds and unit-tests without REAPER.
|
||||||
|
//
|
||||||
|
// WHY THIS EXISTS (docs/product/capture-tail.md §The realtime path). The realtime
|
||||||
|
// backend records a generous tail window, then trims the trailing decay by
|
||||||
|
// truncating the recorded WAV at a frame boundary. Truncating a WAV correctly is
|
||||||
|
// not "chop the bytes": the RIFF container's size fields (the top-level RIFF chunk
|
||||||
|
// size and the `data` sub-chunk size) must be patched to the kept byte count, or
|
||||||
|
// the file is a corrupt / mis-lengthed WAV. That header arithmetic — chunk walking,
|
||||||
|
// format verification, and the size-field patch offsets — is exactly the fiddly,
|
||||||
|
// easy-to-get-wrong logic the discipline unit-tests OUTSIDE the DAW. The REAPER
|
||||||
|
// shell (capture_realtime.cpp) does only the file I/O: read the bytes, call the
|
||||||
|
// pure parse, run the decay scan, call the pure plan, write the truncated bytes.
|
||||||
|
//
|
||||||
|
// FORMAT ASSUMPTION (flagged for DAW-verify). We record 32-bit float WAV
|
||||||
|
// (capture.cpp kRenderFormatWavFloat32; realtime records via REAPER's project
|
||||||
|
// record format, which the manual procedure sets to WAV/32-bit-float). This parser
|
||||||
|
// therefore verifies canonical PCM/IEEE-float WAV: a RIFF/WAVE container, a `fmt `
|
||||||
|
// chunk declaring 32-bit float (format tag 3, or tag 0xFFFE WAVE_FORMAT_EXTENSIBLE
|
||||||
|
// with 32 bits), and a `data` chunk of interleaved little-endian float32. Anything
|
||||||
|
// else (a different depth, a non-WAV, a compressed source) is reported invalid and
|
||||||
|
// the shell SKIPS the trim (keeps the untrimmed window) rather than corrupting a
|
||||||
|
// file it does not understand. This is deliberately conservative.
|
||||||
|
|
||||||
|
#include <cstddef>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "peaks.h" // AudioSample (float)
|
||||||
|
|
||||||
|
namespace reasampler {
|
||||||
|
|
||||||
|
// The parsed geometry of a canonical 32-bit-float WAV. `valid` is false when the
|
||||||
|
// bytes are not a WAV we can safely trim (see FORMAT ASSUMPTION); every other field
|
||||||
|
// is meaningful only when valid.
|
||||||
|
struct WavLayout {
|
||||||
|
bool valid = false;
|
||||||
|
|
||||||
|
std::uint16_t channelCount = 0; // from `fmt ` (the interleave stride)
|
||||||
|
std::uint32_t sampleRate = 0; // from `fmt ` (for frame<->seconds, if needed)
|
||||||
|
|
||||||
|
// The `data` chunk: byte offset of its first PCM byte within the file, and its
|
||||||
|
// declared PCM byte length. frameCount = dataByteLength / (channelCount * 4).
|
||||||
|
std::size_t dataByteOffset = 0;
|
||||||
|
std::size_t dataByteLength = 0;
|
||||||
|
|
||||||
|
// Byte offset of the two little-endian uint32 size fields the truncate patch
|
||||||
|
// rewrites: the top-level RIFF chunk size (bytes 4..7) and the `data` sub-chunk
|
||||||
|
// size (the 4 bytes immediately before dataByteOffset).
|
||||||
|
std::size_t riffSizeFieldOffset = 4; // always 4 for a RIFF file
|
||||||
|
std::size_t dataSizeFieldOffset = 0;
|
||||||
|
|
||||||
|
std::size_t frameCount() const {
|
||||||
|
const std::size_t bytesPerFrame = static_cast<std::size_t>(channelCount) * 4u;
|
||||||
|
return bytesPerFrame ? dataByteLength / bytesPerFrame : 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Parses a WAV byte buffer's header geometry. Returns {valid=false} for anything
|
||||||
|
// that is not a canonical 32-bit-float RIFF/WAVE with a `fmt ` and a `data` chunk,
|
||||||
|
// or whose declared `data` length runs past the buffer. Does NOT copy PCM — it only
|
||||||
|
// locates it (extractFloatFrames does the copy). Pure + total (no throw, no UB).
|
||||||
|
WavLayout parseWavLayout(const std::vector<std::uint8_t>& bytes);
|
||||||
|
|
||||||
|
// Copies `frameCount` interleaved float frames starting at `startFrame` out of the
|
||||||
|
// WAV's `data` region into a flat [f0c0,f0c1,...] buffer (the shape peaks consumes).
|
||||||
|
// Clamps to the frames the buffer actually holds — never reads past `data`. Returns
|
||||||
|
// empty for an invalid layout or an out-of-range start. The floats are read
|
||||||
|
// little-endian via std::memcpy (no aliasing UB); on a big-endian host they would
|
||||||
|
// need a byte-swap — flagged, not handled, because the target (Windows/macOS/Linux
|
||||||
|
// on x86/ARM-LE) is little-endian and REAPER writes LE WAV.
|
||||||
|
std::vector<AudioSample> extractFloatFrames(const std::vector<std::uint8_t>& bytes,
|
||||||
|
const WavLayout& layout,
|
||||||
|
std::size_t startFrame,
|
||||||
|
std::size_t frameCount);
|
||||||
|
|
||||||
|
// The plan to truncate a parsed WAV to `keptFrames` frames: the new total file byte
|
||||||
|
// length and the two size-field values to patch. `valid` is false if the layout is
|
||||||
|
// invalid or keptFrames exceeds the file's frames (never GROW a file — the caller
|
||||||
|
// clamps beforehand; this guards it too).
|
||||||
|
struct WavTruncatePlan {
|
||||||
|
bool valid = false;
|
||||||
|
|
||||||
|
std::size_t newFileByteLength = 0; // truncate the file to exactly this length
|
||||||
|
std::size_t dataSizeFieldOffset = 0; // where to write newDataSize (LE uint32)
|
||||||
|
std::uint32_t newDataSize = 0; // kept PCM byte length
|
||||||
|
std::size_t riffSizeFieldOffset = 4; // where to write newRiffSize (LE uint32)
|
||||||
|
std::uint32_t newRiffSize = 0; // newFileByteLength - 8 (RIFF size excludes
|
||||||
|
// the 8-byte "RIFF"+size prefix)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Computes the truncate plan to keep exactly `keptFrames` frames of a parsed WAV.
|
||||||
|
// keptFrames == layout.frameCount() is a valid no-op plan (file unchanged). Pure +
|
||||||
|
// total. The shell applies it: patch the two size fields in the byte buffer, then
|
||||||
|
// truncate the file to newFileByteLength.
|
||||||
|
WavTruncatePlan planWavTruncate(const WavLayout& layout, std::size_t keptFrames);
|
||||||
|
|
||||||
|
} // namespace reasampler
|
||||||
@@ -278,6 +278,76 @@ static void testLargeBinCountOverflowGuard() {
|
|||||||
CHECK(env[0][7].min == 0.0f && env[0][7].max == 0.0f);
|
CHECK(env[0][7].min == 0.0f && env[0][7].max == 0.0f);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- lastFrameAboveThreshold: the realtime tail's decay-scan boundary primitive --
|
||||||
|
|
||||||
|
// A mono decaying ramp: frame i has amplitude that falls linearly to zero. With a
|
||||||
|
// threshold set between two frames' levels, the last frame above it is deterministic.
|
||||||
|
static void testLastFrameDecayingRamp() {
|
||||||
|
// 10 mono frames, amplitude 1.0 - i*0.1: frame0=1.0 ... frame9=0.1.
|
||||||
|
std::vector<AudioSample> buf(10);
|
||||||
|
for (std::size_t i = 0; i < 10; ++i) buf[i] = 1.0f - 0.1f * (float)i;
|
||||||
|
|
||||||
|
// Threshold 0.35: frames 0..6 (levels 1.0..0.4) exceed it; frame 6 is the last
|
||||||
|
// (level 0.4 > 0.35), frame 7 (0.3) does not. Strict > semantics.
|
||||||
|
CHECK(lastFrameAboveThreshold(buf, 1, 10, 0.35f) == 6);
|
||||||
|
|
||||||
|
// Threshold just under frame 9's level (0.1): the very last frame stays.
|
||||||
|
CHECK(lastFrameAboveThreshold(buf, 1, 10, 0.05f) == 9);
|
||||||
|
|
||||||
|
// Threshold above the loudest frame: nothing survives.
|
||||||
|
CHECK(lastFrameAboveThreshold(buf, 1, 10, 1.5f) == kNoFrameAboveThreshold);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pure silence at or below the threshold -> sentinel (the "trim back to end" case:
|
||||||
|
// no frame in the tail window exceeds -72 dB).
|
||||||
|
static void testLastFrameSilence() {
|
||||||
|
std::vector<AudioSample> zeros(20, 0.0f);
|
||||||
|
CHECK(lastFrameAboveThreshold(zeros, 2, 10, 0.001f) == kNoFrameAboveThreshold);
|
||||||
|
|
||||||
|
// A DC level exactly AT the threshold does not count (strict >).
|
||||||
|
std::vector<AudioSample> atThresh(8, 0.25f);
|
||||||
|
CHECK(lastFrameAboveThreshold(atThresh, 1, 8, 0.25f) == kNoFrameAboveThreshold);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every frame above the threshold (a non-decaying source): the last frame is the
|
||||||
|
// boundary — the caller keeps the whole window (the 8 s cap did its job).
|
||||||
|
static void testLastFrameAllAbove() {
|
||||||
|
std::vector<AudioSample> loud(12, 0.8f); // 6 stereo frames
|
||||||
|
CHECK(lastFrameAboveThreshold(loud, 2, 6, 0.1f) == 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-frame peak is the MAX abs across channels (no fold): a frame with one loud
|
||||||
|
// channel and one silent channel is "above" on the strength of the loud one, and a
|
||||||
|
// negative sample is compared by magnitude.
|
||||||
|
static void testLastFramePerChannelMaxAbs() {
|
||||||
|
// 3 stereo frames. Frame0: (0.9, 0.0) loud L. Frame1: (0.0, -0.9) loud R (negative
|
||||||
|
// -> abs). Frame2: (0.05, -0.05) both quiet.
|
||||||
|
std::vector<AudioSample> buf = {0.9f, 0.0f, 0.0f, -0.9f, 0.05f, -0.05f};
|
||||||
|
// Threshold 0.5: frame2 is below (peak 0.05), frame1 is above (|-0.9|=0.9).
|
||||||
|
CHECK(lastFrameAboveThreshold(buf, 2, 3, 0.5f) == 1);
|
||||||
|
// If both channels of the last frame mattered independently, a fold-average
|
||||||
|
// (0.9+0.0)/2 = 0.45 on frame0 would fall below 0.5 — but frame0's L alone (0.9)
|
||||||
|
// is above, proving max-abs, not average. Lower the threshold to isolate frame0.
|
||||||
|
std::vector<AudioSample> f0 = {0.9f, 0.0f};
|
||||||
|
CHECK(lastFrameAboveThreshold(f0, 2, 1, 0.5f) == 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Degenerate: zero channels, zero frames, and a frameCount that overstates the
|
||||||
|
// buffer (must clamp to available frames, no OOB read).
|
||||||
|
static void testLastFrameDegenerate() {
|
||||||
|
std::vector<AudioSample> buf = {0.5f, 0.5f, 0.5f, 0.5f}; // 2 stereo frames
|
||||||
|
|
||||||
|
CHECK(lastFrameAboveThreshold(buf, 0, 2, 0.1f) == kNoFrameAboveThreshold);
|
||||||
|
CHECK(lastFrameAboveThreshold(buf, 2, 0, 0.1f) == kNoFrameAboveThreshold);
|
||||||
|
|
||||||
|
std::vector<AudioSample> empty;
|
||||||
|
CHECK(lastFrameAboveThreshold(empty, 2, 10, 0.1f) == kNoFrameAboveThreshold);
|
||||||
|
|
||||||
|
// frameCount=100 but only 2 real stereo frames: clamps to frame 1 (the last real
|
||||||
|
// frame), which is above -> index 1, no read past the buffer.
|
||||||
|
CHECK(lastFrameAboveThreshold(buf, 2, 100, 0.1f) == 1);
|
||||||
|
}
|
||||||
|
|
||||||
int main() {
|
int main() {
|
||||||
testSineEnvelope();
|
testSineEnvelope();
|
||||||
testRampMonotonic();
|
testRampMonotonic();
|
||||||
@@ -289,6 +359,11 @@ int main() {
|
|||||||
testSingleBinWholeBuffer();
|
testSingleBinWholeBuffer();
|
||||||
testDegenerateInputs();
|
testDegenerateInputs();
|
||||||
testLargeBinCountOverflowGuard();
|
testLargeBinCountOverflowGuard();
|
||||||
|
testLastFrameDecayingRamp();
|
||||||
|
testLastFrameSilence();
|
||||||
|
testLastFrameAllAbove();
|
||||||
|
testLastFramePerChannelMaxAbs();
|
||||||
|
testLastFrameDegenerate();
|
||||||
|
|
||||||
if (g_fail == 0) std::printf("All tests passed.\n");
|
if (g_fail == 0) std::printf("All tests passed.\n");
|
||||||
return g_fail ? 1 : 0;
|
return g_fail ? 1 : 0;
|
||||||
|
|||||||
@@ -126,6 +126,31 @@ static void testTailManualClampsToCap() {
|
|||||||
CHECK(tailRenderSettingsFor(TailMode::Manual, -50.0).tailMs == 0.0);
|
CHECK(tailRenderSettingsFor(TailMode::Manual, -50.0).tailMs == 0.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- realtimeRecordWindowEnd: the T2 record-window extension -----------------
|
||||||
|
|
||||||
|
static void testRealtimeWindowNoneIsExact() {
|
||||||
|
// None -> the exact range end, no extra recording (byte-identical to today).
|
||||||
|
CHECK(realtimeRecordWindowEnd(TailMode::None, 12.5, 2000.0) == 12.5);
|
||||||
|
// manualTailMs is ignored for None.
|
||||||
|
CHECK(realtimeRecordWindowEnd(TailMode::None, 12.5, 0.0) == 12.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testRealtimeWindowAutoAddsCap() {
|
||||||
|
// Auto -> range end + the 8 s runaway cap (trimmed later by the decay scan).
|
||||||
|
CHECK(realtimeRecordWindowEnd(TailMode::Auto, 10.0, 0.0) == 10.0 + kMaxTailSeconds);
|
||||||
|
// manualTailMs is ignored for Auto (the cap is fixed).
|
||||||
|
CHECK(realtimeRecordWindowEnd(TailMode::Auto, 10.0, 3000.0) == 10.0 + kMaxTailSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testRealtimeWindowManualAddsClampedLength() {
|
||||||
|
// Manual -> range end + the set length in seconds (fixed, no trim).
|
||||||
|
CHECK(realtimeRecordWindowEnd(TailMode::Manual, 5.0, 2000.0) == 5.0 + 2.0);
|
||||||
|
// Clamped to the 8 s cap: > 8000 ms -> +8 s.
|
||||||
|
CHECK(realtimeRecordWindowEnd(TailMode::Manual, 5.0, 9000.0) == 5.0 + kMaxTailSeconds);
|
||||||
|
// Negative floors to 0 -> no extra window (never records before the range end).
|
||||||
|
CHECK(realtimeRecordWindowEnd(TailMode::Manual, 5.0, -100.0) == 5.0);
|
||||||
|
}
|
||||||
|
|
||||||
// --- parseRazorEdits: P_RAZOREDITS string -> ranges --------------------------
|
// --- parseRazorEdits: P_RAZOREDITS string -> ranges --------------------------
|
||||||
|
|
||||||
static void testParseSingleTrackAudioArea() {
|
static void testParseSingleTrackAudioArea() {
|
||||||
@@ -267,6 +292,9 @@ int main() {
|
|||||||
testAutoTrimRatioDerivesFromDb();
|
testAutoTrimRatioDerivesFromDb();
|
||||||
testTailManualFixedNoTrim();
|
testTailManualFixedNoTrim();
|
||||||
testTailManualClampsToCap();
|
testTailManualClampsToCap();
|
||||||
|
testRealtimeWindowNoneIsExact();
|
||||||
|
testRealtimeWindowAutoAddsCap();
|
||||||
|
testRealtimeWindowManualAddsClampedLength();
|
||||||
testParseSingleTrackAudioArea();
|
testParseSingleTrackAudioArea();
|
||||||
testParseMultipleAreas();
|
testParseMultipleAreas();
|
||||||
testParseSkipsEnvelopeLaneAreas();
|
testParseSkipsEnvelopeLaneAreas();
|
||||||
|
|||||||
@@ -0,0 +1,372 @@
|
|||||||
|
// Standalone tests for reasampler::wav_trim — no REAPER, no test framework.
|
||||||
|
// Builds synthetic 32-bit-float WAV byte buffers, asserts the parse geometry, the
|
||||||
|
// float extraction, and the truncate-plan arithmetic (the header size-field patch).
|
||||||
|
//
|
||||||
|
// Covers: canonical stereo/mono 32-bit-float parse; a leading unknown chunk skipped;
|
||||||
|
// format rejection (16-bit PCM, non-WAV, data-before-fmt, truncated data); frame
|
||||||
|
// extraction (whole / tail window / clamp / out-of-range); truncate plan (kept<all,
|
||||||
|
// no-op keep-all, kept==0, grow rejected) with exact size-field values.
|
||||||
|
|
||||||
|
#include "../src/wav_trim.h"
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
#include <cstdio>
|
||||||
|
#include <cstring>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
using namespace reasampler;
|
||||||
|
|
||||||
|
static int g_fail = 0;
|
||||||
|
#define CHECK(cond) do { if(!(cond)) { \
|
||||||
|
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
|
||||||
|
|
||||||
|
// --- Synthetic WAV builder ---------------------------------------------------
|
||||||
|
|
||||||
|
static void putU16(std::vector<std::uint8_t>& b, std::uint16_t v) {
|
||||||
|
b.push_back(static_cast<std::uint8_t>(v & 0xFF));
|
||||||
|
b.push_back(static_cast<std::uint8_t>((v >> 8) & 0xFF));
|
||||||
|
}
|
||||||
|
static void putU32(std::vector<std::uint8_t>& b, std::uint32_t v) {
|
||||||
|
b.push_back(static_cast<std::uint8_t>(v & 0xFF));
|
||||||
|
b.push_back(static_cast<std::uint8_t>((v >> 8) & 0xFF));
|
||||||
|
b.push_back(static_cast<std::uint8_t>((v >> 16) & 0xFF));
|
||||||
|
b.push_back(static_cast<std::uint8_t>((v >> 24) & 0xFF));
|
||||||
|
}
|
||||||
|
static void putTag(std::vector<std::uint8_t>& b, const char* t) {
|
||||||
|
for (int i = 0; i < 4; ++i) b.push_back(static_cast<std::uint8_t>(t[i]));
|
||||||
|
}
|
||||||
|
static void putFloat(std::vector<std::uint8_t>& b, float f) {
|
||||||
|
std::uint8_t tmp[4];
|
||||||
|
std::memcpy(tmp, &f, 4);
|
||||||
|
for (int i = 0; i < 4; ++i) b.push_back(tmp[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A canonical 32-bit-float WAV: RIFF/WAVE, fmt (tag 3, 16-byte body), data holding
|
||||||
|
// `frames` interleaved frames of `channels`. `leadingJunk` optionally inserts an
|
||||||
|
// unknown chunk before fmt to exercise the chunk walk. Samples: frame f, channel c
|
||||||
|
// = value(f,c).
|
||||||
|
template <typename Fn>
|
||||||
|
static std::vector<std::uint8_t> buildFloatWav(std::uint16_t channels,
|
||||||
|
std::uint32_t sampleRate,
|
||||||
|
std::size_t frames,
|
||||||
|
Fn value,
|
||||||
|
bool leadingJunk = false,
|
||||||
|
std::uint16_t fmtTag = 3,
|
||||||
|
std::uint16_t bits = 32) {
|
||||||
|
const std::uint32_t dataBytes =
|
||||||
|
static_cast<std::uint32_t>(frames * channels * (bits / 8));
|
||||||
|
|
||||||
|
std::vector<std::uint8_t> chunks; // everything after "WAVE"
|
||||||
|
if (leadingJunk) {
|
||||||
|
putTag(chunks, "LIST");
|
||||||
|
putU32(chunks, 4);
|
||||||
|
putTag(chunks, "INFO"); // 4-byte body, even -> no pad
|
||||||
|
}
|
||||||
|
// fmt chunk (16-byte body).
|
||||||
|
putTag(chunks, "fmt ");
|
||||||
|
putU32(chunks, 16);
|
||||||
|
putU16(chunks, fmtTag); // format tag
|
||||||
|
putU16(chunks, channels);
|
||||||
|
putU32(chunks, sampleRate);
|
||||||
|
const std::uint32_t byteRate = sampleRate * channels * (bits / 8);
|
||||||
|
putU32(chunks, byteRate);
|
||||||
|
putU16(chunks, static_cast<std::uint16_t>(channels * (bits / 8))); // block align
|
||||||
|
putU16(chunks, bits);
|
||||||
|
// data chunk.
|
||||||
|
putTag(chunks, "data");
|
||||||
|
putU32(chunks, dataBytes);
|
||||||
|
for (std::size_t f = 0; f < frames; ++f)
|
||||||
|
for (std::uint16_t c = 0; c < channels; ++c)
|
||||||
|
putFloat(chunks, value(f, c));
|
||||||
|
|
||||||
|
std::vector<std::uint8_t> wav;
|
||||||
|
putTag(wav, "RIFF");
|
||||||
|
putU32(wav, static_cast<std::uint32_t>(4 + chunks.size())); // "WAVE" + chunks
|
||||||
|
putTag(wav, "WAVE");
|
||||||
|
wav.insert(wav.end(), chunks.begin(), chunks.end());
|
||||||
|
return wav;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Builds a WAVE_FORMAT_EXTENSIBLE (0xFFFE) WAV with a 40-byte fmt body.
|
||||||
|
// `subFormatTag` is the 2-byte leading tag embedded in the SubFormat GUID:
|
||||||
|
// 0x0003 = IEEE float, 0x0001 = PCM integer (and any other value to exercise rejection).
|
||||||
|
// bitsPerSample and the PCM data are always 32-bit float bytes regardless of subFormatTag
|
||||||
|
// (we're testing that the parser correctly rejects/accepts based on the GUID, not the data).
|
||||||
|
template <typename Fn>
|
||||||
|
static std::vector<std::uint8_t> buildExtensibleWav(std::uint16_t channels,
|
||||||
|
std::uint32_t sampleRate,
|
||||||
|
std::size_t frames,
|
||||||
|
Fn value,
|
||||||
|
std::uint16_t subFormatTag) {
|
||||||
|
const std::uint32_t dataBytes =
|
||||||
|
static_cast<std::uint32_t>(frames * channels * 4u);
|
||||||
|
|
||||||
|
// WAVEFORMATEXTENSIBLE fmt body (40 bytes):
|
||||||
|
// [0..1] wFormatTag = 0xFFFE
|
||||||
|
// [2..3] nChannels
|
||||||
|
// [4..7] nSamplesPerSec
|
||||||
|
// [8..11] nAvgBytesPerSec
|
||||||
|
// [12..13] nBlockAlign
|
||||||
|
// [14..15] wBitsPerSample = 32
|
||||||
|
// [16..17] cbSize = 22 (extension size beyond the 18-byte WAVEFORMATEX)
|
||||||
|
// [18..19] wValidBitsPerSample = 32
|
||||||
|
// [20..23] dwChannelMask = 0
|
||||||
|
// [24..39] SubFormat GUID: first 2 bytes = subFormatTag (LE), rest = standard
|
||||||
|
// KSDATAFORMAT_SUBTYPE base GUID {00000000-0000-0010-8000-00aa00389b71}
|
||||||
|
std::vector<std::uint8_t> fmt;
|
||||||
|
putU16(fmt, 0xFFFE); // wFormatTag
|
||||||
|
putU16(fmt, channels); // nChannels
|
||||||
|
putU32(fmt, sampleRate); // nSamplesPerSec
|
||||||
|
putU32(fmt, sampleRate * channels * 4u); // nAvgBytesPerSec
|
||||||
|
putU16(fmt, static_cast<std::uint16_t>(channels * 4)); // nBlockAlign
|
||||||
|
putU16(fmt, 32); // wBitsPerSample
|
||||||
|
putU16(fmt, 22); // cbSize
|
||||||
|
putU16(fmt, 32); // wValidBitsPerSample
|
||||||
|
putU32(fmt, 0); // dwChannelMask
|
||||||
|
// SubFormat GUID (16 bytes): [subFormatTag, 0x0000, 0x00, 0x00, 0x10, 0x00,
|
||||||
|
// 0x80, 0x00, 0x00, 0xaa, 0x00, 0x38, 0x9b, 0x71]
|
||||||
|
putU16(fmt, subFormatTag); // bytes [24..25]: the effective format tag
|
||||||
|
putU16(fmt, 0x0000); // bytes [26..27]
|
||||||
|
fmt.push_back(0x00); fmt.push_back(0x00); // bytes [28..29]
|
||||||
|
fmt.push_back(0x10); fmt.push_back(0x00); // bytes [30..31]
|
||||||
|
fmt.push_back(0x80); fmt.push_back(0x00); // bytes [32..33]
|
||||||
|
fmt.push_back(0x00); fmt.push_back(0xaa); // bytes [34..35]
|
||||||
|
fmt.push_back(0x00); fmt.push_back(0x38); // bytes [36..37]
|
||||||
|
fmt.push_back(0x9b); fmt.push_back(0x71); // bytes [38..39]
|
||||||
|
|
||||||
|
std::vector<std::uint8_t> chunks;
|
||||||
|
putTag(chunks, "fmt ");
|
||||||
|
putU32(chunks, static_cast<std::uint32_t>(fmt.size())); // 40
|
||||||
|
chunks.insert(chunks.end(), fmt.begin(), fmt.end());
|
||||||
|
putTag(chunks, "data");
|
||||||
|
putU32(chunks, dataBytes);
|
||||||
|
for (std::size_t f = 0; f < frames; ++f)
|
||||||
|
for (std::uint16_t c = 0; c < channels; ++c)
|
||||||
|
putFloat(chunks, value(f, c));
|
||||||
|
|
||||||
|
std::vector<std::uint8_t> wav;
|
||||||
|
putTag(wav, "RIFF");
|
||||||
|
putU32(wav, static_cast<std::uint32_t>(4 + chunks.size()));
|
||||||
|
putTag(wav, "WAVE");
|
||||||
|
wav.insert(wav.end(), chunks.begin(), chunks.end());
|
||||||
|
return wav;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Parse tests -------------------------------------------------------------
|
||||||
|
|
||||||
|
static void testParseCanonicalStereo() {
|
||||||
|
auto wav = buildFloatWav(2, 48000, 5,
|
||||||
|
[](std::size_t f, std::uint16_t c) {
|
||||||
|
return static_cast<float>(f) + 0.1f * c;
|
||||||
|
});
|
||||||
|
WavLayout L = parseWavLayout(wav);
|
||||||
|
CHECK(L.valid);
|
||||||
|
CHECK(L.channelCount == 2);
|
||||||
|
CHECK(L.sampleRate == 48000);
|
||||||
|
CHECK(L.dataByteLength == 5 * 2 * 4);
|
||||||
|
CHECK(L.frameCount() == 5);
|
||||||
|
// data body sits after RIFF(12) + fmt(8 header + 16 body) + data(8 header) = 44.
|
||||||
|
CHECK(L.dataByteOffset == 44);
|
||||||
|
CHECK(L.dataSizeFieldOffset == 40); // the 4 bytes before dataByteOffset
|
||||||
|
CHECK(L.riffSizeFieldOffset == 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testParseMonoAndLeadingChunk() {
|
||||||
|
// A leading LIST/INFO chunk before fmt must be skipped by the walk.
|
||||||
|
auto wav = buildFloatWav(1, 44100, 3,
|
||||||
|
[](std::size_t f, std::uint16_t) {
|
||||||
|
return static_cast<float>(f);
|
||||||
|
},
|
||||||
|
/*leadingJunk=*/true);
|
||||||
|
WavLayout L = parseWavLayout(wav);
|
||||||
|
CHECK(L.valid);
|
||||||
|
CHECK(L.channelCount == 1);
|
||||||
|
CHECK(L.frameCount() == 3);
|
||||||
|
// Data still parses correctly despite the leading chunk shifting its offset.
|
||||||
|
auto pcm = extractFloatFrames(wav, L, 0, 3);
|
||||||
|
CHECK(pcm.size() == 3);
|
||||||
|
CHECK(pcm[0] == 0.0f && pcm[1] == 1.0f && pcm[2] == 2.0f);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testParseRejectsNon32BitAndNonWav() {
|
||||||
|
// 16-bit PCM (tag 1, bits 16) -> rejected.
|
||||||
|
auto pcm16 = buildFloatWav(2, 48000, 4,
|
||||||
|
[](std::size_t, std::uint16_t) { return 0.0f; },
|
||||||
|
false, /*fmtTag=*/1, /*bits=*/16);
|
||||||
|
CHECK(!parseWavLayout(pcm16).valid);
|
||||||
|
|
||||||
|
// Not a RIFF file.
|
||||||
|
std::vector<std::uint8_t> junk = {'N','O','P','E', 0,0,0,0, 'W','A','V','E'};
|
||||||
|
CHECK(!parseWavLayout(junk).valid);
|
||||||
|
|
||||||
|
// Too short to hold even the RIFF header.
|
||||||
|
std::vector<std::uint8_t> tiny = {'R','I','F','F'};
|
||||||
|
CHECK(!parseWavLayout(tiny).valid);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testParseRejectsLyingDataLength() {
|
||||||
|
// Build a valid WAV, then inflate the `data` size field so it claims more bytes
|
||||||
|
// than the buffer holds -> must be rejected (no OOB trust).
|
||||||
|
auto wav = buildFloatWav(2, 48000, 4,
|
||||||
|
[](std::size_t, std::uint16_t) { return 1.0f; });
|
||||||
|
WavLayout good = parseWavLayout(wav);
|
||||||
|
CHECK(good.valid);
|
||||||
|
// Overwrite the data size field with a huge value.
|
||||||
|
wav[good.dataSizeFieldOffset + 0] = 0xFF;
|
||||||
|
wav[good.dataSizeFieldOffset + 1] = 0xFF;
|
||||||
|
wav[good.dataSizeFieldOffset + 2] = 0xFF;
|
||||||
|
wav[good.dataSizeFieldOffset + 3] = 0x7F;
|
||||||
|
CHECK(!parseWavLayout(wav).valid);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Extraction tests --------------------------------------------------------
|
||||||
|
|
||||||
|
static void testExtractTailWindow() {
|
||||||
|
// Stereo, 10 frames. Sample value encodes frame+channel so a mis-index is caught.
|
||||||
|
auto wav = buildFloatWav(2, 48000, 10,
|
||||||
|
[](std::size_t f, std::uint16_t c) {
|
||||||
|
return static_cast<float>(f) * 10.0f + c;
|
||||||
|
});
|
||||||
|
WavLayout L = parseWavLayout(wav);
|
||||||
|
CHECK(L.valid);
|
||||||
|
|
||||||
|
// The "tail region" the realtime trim scans: frames 6..9 (start at frame 6).
|
||||||
|
auto tail = extractFloatFrames(wav, L, 6, 100 /*clamps*/);
|
||||||
|
CHECK(tail.size() == 4 * 2); // frames 6,7,8,9, 2 channels each
|
||||||
|
CHECK(tail[0] == 60.0f && tail[1] == 61.0f); // frame 6: L=60,R=61
|
||||||
|
CHECK(tail[6] == 90.0f && tail[7] == 91.0f); // frame 9: L=90,R=91
|
||||||
|
|
||||||
|
// Out-of-range start -> empty.
|
||||||
|
CHECK(extractFloatFrames(wav, L, 10, 4).empty());
|
||||||
|
CHECK(extractFloatFrames(wav, L, 99, 4).empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Truncate-plan tests -----------------------------------------------------
|
||||||
|
|
||||||
|
static void testTruncatePlanKeepFewer() {
|
||||||
|
auto wav = buildFloatWav(2, 48000, 10,
|
||||||
|
[](std::size_t, std::uint16_t) { return 0.0f; });
|
||||||
|
WavLayout L = parseWavLayout(wav);
|
||||||
|
CHECK(L.valid);
|
||||||
|
|
||||||
|
// Keep 4 of 10 frames.
|
||||||
|
WavTruncatePlan p = planWavTruncate(L, 4);
|
||||||
|
CHECK(p.valid);
|
||||||
|
const std::size_t bpf = 2 * 4; // channels * 4 bytes
|
||||||
|
CHECK(p.newDataSize == 4 * bpf); // 32 bytes of PCM kept
|
||||||
|
CHECK(p.newFileByteLength == L.dataByteOffset + 4 * bpf); // 44 + 32 = 76
|
||||||
|
CHECK(p.newRiffSize == p.newFileByteLength - 8);
|
||||||
|
CHECK(p.dataSizeFieldOffset == L.dataSizeFieldOffset);
|
||||||
|
CHECK(p.riffSizeFieldOffset == 4);
|
||||||
|
|
||||||
|
// Applying the plan yields a buffer that re-parses to exactly 4 frames.
|
||||||
|
std::vector<std::uint8_t> trimmed(wav.begin(),
|
||||||
|
wav.begin() + p.newFileByteLength);
|
||||||
|
// Patch the two size fields (what the shell does before truncating on disk).
|
||||||
|
auto writeU32 = [](std::vector<std::uint8_t>& b, std::size_t off, std::uint32_t v) {
|
||||||
|
b[off + 0] = static_cast<std::uint8_t>(v & 0xFF);
|
||||||
|
b[off + 1] = static_cast<std::uint8_t>((v >> 8) & 0xFF);
|
||||||
|
b[off + 2] = static_cast<std::uint8_t>((v >> 16) & 0xFF);
|
||||||
|
b[off + 3] = static_cast<std::uint8_t>((v >> 24) & 0xFF);
|
||||||
|
};
|
||||||
|
writeU32(trimmed, p.dataSizeFieldOffset, p.newDataSize);
|
||||||
|
writeU32(trimmed, p.riffSizeFieldOffset, p.newRiffSize);
|
||||||
|
|
||||||
|
WavLayout L2 = parseWavLayout(trimmed);
|
||||||
|
CHECK(L2.valid);
|
||||||
|
CHECK(L2.frameCount() == 4);
|
||||||
|
CHECK(L2.dataByteLength == 4 * bpf);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testTruncatePlanKeepAllIsNoOp() {
|
||||||
|
auto wav = buildFloatWav(1, 48000, 6,
|
||||||
|
[](std::size_t, std::uint16_t) { return 0.0f; });
|
||||||
|
WavLayout L = parseWavLayout(wav);
|
||||||
|
WavTruncatePlan p = planWavTruncate(L, 6); // keep all
|
||||||
|
CHECK(p.valid);
|
||||||
|
CHECK(p.newFileByteLength == wav.size()); // unchanged
|
||||||
|
CHECK(p.newDataSize == L.dataByteLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Extensible format tests -------------------------------------------------
|
||||||
|
|
||||||
|
// A WAVE_FORMAT_EXTENSIBLE fmt with SubFormat tag 0x0001 (PCM integer) and
|
||||||
|
// bitsPerSample==32 must be REJECTED — it is 32-bit integer, not 32-bit float.
|
||||||
|
static void testExtensiblePcmIntegerRejected() {
|
||||||
|
auto wav = buildExtensibleWav(2, 48000, 4,
|
||||||
|
[](std::size_t, std::uint16_t) { return 0.0f; },
|
||||||
|
/*subFormatTag=*/0x0001); // PCM integer
|
||||||
|
CHECK(!parseWavLayout(wav).valid);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A WAVE_FORMAT_EXTENSIBLE fmt with SubFormat tag 0x0003 (IEEE float) and
|
||||||
|
// bitsPerSample==32 must be ACCEPTED and parse + trim correctly.
|
||||||
|
static void testExtensibleFloatAccepted() {
|
||||||
|
auto wav = buildExtensibleWav(2, 48000, 5,
|
||||||
|
[](std::size_t f, std::uint16_t c) {
|
||||||
|
return static_cast<float>(f) + 0.1f * c;
|
||||||
|
},
|
||||||
|
/*subFormatTag=*/0x0003); // IEEE float
|
||||||
|
WavLayout L = parseWavLayout(wav);
|
||||||
|
CHECK(L.valid);
|
||||||
|
CHECK(L.channelCount == 2);
|
||||||
|
CHECK(L.sampleRate == 48000);
|
||||||
|
CHECK(L.frameCount() == 5);
|
||||||
|
|
||||||
|
// Frame extraction works correctly.
|
||||||
|
auto pcm = extractFloatFrames(wav, L, 0, 2);
|
||||||
|
CHECK(pcm.size() == 4);
|
||||||
|
CHECK(pcm[0] == 0.0f); // frame 0, channel 0
|
||||||
|
CHECK(pcm[1] == 0.1f); // frame 0, channel 1
|
||||||
|
|
||||||
|
// Truncate plan is valid and re-parses cleanly.
|
||||||
|
WavTruncatePlan p = planWavTruncate(L, 3);
|
||||||
|
CHECK(p.valid);
|
||||||
|
CHECK(p.newDataSize == 3 * 2 * 4u);
|
||||||
|
std::vector<std::uint8_t> trimmed(wav.begin(), wav.begin() + p.newFileByteLength);
|
||||||
|
auto writeU32 = [](std::vector<std::uint8_t>& b, std::size_t off, std::uint32_t v) {
|
||||||
|
b[off + 0] = static_cast<std::uint8_t>(v & 0xFF);
|
||||||
|
b[off + 1] = static_cast<std::uint8_t>((v >> 8) & 0xFF);
|
||||||
|
b[off + 2] = static_cast<std::uint8_t>((v >> 16) & 0xFF);
|
||||||
|
b[off + 3] = static_cast<std::uint8_t>((v >> 24) & 0xFF);
|
||||||
|
};
|
||||||
|
writeU32(trimmed, p.dataSizeFieldOffset, p.newDataSize);
|
||||||
|
writeU32(trimmed, p.riffSizeFieldOffset, p.newRiffSize);
|
||||||
|
WavLayout L2 = parseWavLayout(trimmed);
|
||||||
|
CHECK(L2.valid);
|
||||||
|
CHECK(L2.frameCount() == 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testTruncatePlanKeepZeroAndGrowRejected() {
|
||||||
|
auto wav = buildFloatWav(2, 48000, 5,
|
||||||
|
[](std::size_t, std::uint16_t) { return 0.0f; });
|
||||||
|
WavLayout L = parseWavLayout(wav);
|
||||||
|
|
||||||
|
WavTruncatePlan zero = planWavTruncate(L, 0);
|
||||||
|
CHECK(zero.valid);
|
||||||
|
CHECK(zero.newDataSize == 0);
|
||||||
|
CHECK(zero.newFileByteLength == L.dataByteOffset); // header only
|
||||||
|
|
||||||
|
// keptFrames > total -> refused (never grow a file).
|
||||||
|
CHECK(!planWavTruncate(L, 6).valid);
|
||||||
|
|
||||||
|
// Invalid layout -> invalid plan.
|
||||||
|
WavLayout bad;
|
||||||
|
CHECK(!planWavTruncate(bad, 0).valid);
|
||||||
|
}
|
||||||
|
|
||||||
|
int main() {
|
||||||
|
testParseCanonicalStereo();
|
||||||
|
testParseMonoAndLeadingChunk();
|
||||||
|
testParseRejectsNon32BitAndNonWav();
|
||||||
|
testParseRejectsLyingDataLength();
|
||||||
|
testExtractTailWindow();
|
||||||
|
testTruncatePlanKeepFewer();
|
||||||
|
testTruncatePlanKeepAllIsNoOp();
|
||||||
|
testTruncatePlanKeepZeroAndGrowRejected();
|
||||||
|
testExtensiblePcmIntegerRejected();
|
||||||
|
testExtensibleFloatAccepted();
|
||||||
|
|
||||||
|
if (g_fail == 0) std::printf("All tests passed.\n");
|
||||||
|
return g_fail ? 1 : 0;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user