diff --git a/CMakeLists.txt b/CMakeLists.txt index 4bd664e..8284730 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -142,6 +142,20 @@ add_library(realtime_record STATIC src/realtime_record.cpp) target_include_directories(realtime_record PUBLIC src) 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). # --------------------------------------------------------------------------- @@ -198,6 +212,10 @@ add_executable(realtime_record_tests tests/test_realtime_record.cpp) target_link_libraries(realtime_record_tests PRIVATE realtime_record) 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). # --------------------------------------------------------------------------- @@ -231,7 +249,7 @@ add_library(reaper_reasampler MODULE src/lane_keys.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}) set_target_properties(reaper_reasampler PROPERTIES PREFIX "" OUTPUT_NAME "reaper_reasampler") diff --git a/src/capture_realtime.cpp b/src/capture_realtime.cpp index d20290f..4635c11 100644 --- a/src/capture_realtime.cpp +++ b/src/capture_realtime.cpp @@ -71,13 +71,18 @@ #include #include +#include #include #include +#include #include #include #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 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 bytes(static_cast(size)); + f.seekg(0); + f.read(reinterpret_cast(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& bytes, std::size_t off, std::uint32_t v) { + bytes[off + 0] = static_cast(v & 0xFF); + bytes[off + 1] = static_cast((v >> 8) & 0xFF); + bytes[off + 2] = static_cast((v >> 16) & 0xFF); + bytes[off + 3] = static_cast((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 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( + rangeSeconds * static_cast(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 tailPcm = + extractFloatFrames(bytes, layout, rangeEndFrame, tailFrames); + if (tailPcm.empty()) return kNoTrim; + + const float threshold = static_cast(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(bytes.data()), + static_cast(plan.newFileByteLength)); + if (!out) return kNoTrim; + out.close(); + + // The trimmed length in seconds for the Sample metadata. + return static_cast(keptFrames) / static_cast(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_)) diff --git a/src/main.cpp b/src/main.cpp index 200a11a..5763147 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -596,13 +596,20 @@ static void RunCaptureRealtimeTrack() 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; req.sourceMode = reasampler::SourceMode::SelectedTracks; // realtime track scope req.startSeconds = src.startSeconds; // exact bounds — no rounding req.endSeconds = src.endSeconds; req.wetDry = 1.0; // fully wet (post-fader tap) - req.tailMode = reasampler::TailMode::None; // realtime tail is T2; exact bounds here - req.tailMs = 0.0; + req.tailMode = tail.mode; // None / Auto / Manual, from the panel toggle + req.tailMs = tail.manualMs; // Manual-only (pre-clamped); ignored for None/Auto req.sampleRate = 0; // follow project rate req.channelCount = 2; req.bitDepth = reasampler::WavBitDepth::Float32; @@ -623,8 +630,15 @@ static void RunCaptureRealtimeTrack() // completion across ticks (UI stays responsive). g_rtCaptureProject = EnumProjects(-1, nullptr, 0); g_rtCapture = std::move(st); - ShowConsoleMsg("ReaSampler: realtime capture started — recording in the " - "background; the bank updates when it reaches the range end.\n"); + // With a tail mode the recorded window runs PAST the range end (Auto: +8 s then + // 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 diff --git a/src/peaks.cpp b/src/peaks.cpp index d2136fd..af65994 100644 --- a/src/peaks.cpp +++ b/src/peaks.cpp @@ -2,6 +2,7 @@ #include #include +#include // peaks implementation. // @@ -63,4 +64,31 @@ Envelope computeEnvelope(const std::vector& interleaved, return envelope; } +std::size_t lastFrameAboveThreshold(const std::vector& 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 diff --git a/src/peaks.h b/src/peaks.h index f967c7d..dceeee5 100644 --- a/src/peaks.h +++ b/src/peaks.h @@ -66,4 +66,42 @@ Envelope computeEnvelope(const std::vector& interleaved, std::size_t frameCount, 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(-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& interleaved, + std::size_t channelCount, + std::size_t frameCount, + AudioSample linearThreshold); + } // namespace reasampler diff --git a/src/render_settings.cpp b/src/render_settings.cpp index 3f4e3ac..6a38129 100644 --- a/src/render_settings.cpp +++ b/src/render_settings.cpp @@ -56,6 +56,24 @@ TailRenderSettings tailRenderSettingsFor(TailMode mode, double manualTailMs) { 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*/) { // `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 diff --git a/src/render_settings.h b/src/render_settings.h index e331fe9..ab79da6 100644 --- a/src/render_settings.h +++ b/src/render_settings.h @@ -114,6 +114,20 @@ struct TailRenderSettings { // the Auto default or an explicit request (spec §Manual override). Pure + tested. 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 // for SourceMode::Realtime (that is the M8 backend, not offline render). struct RenderSettingsChoice { diff --git a/src/wav_trim.cpp b/src/wav_trim.cpp new file mode 100644 index 0000000..e8265ae --- /dev/null +++ b/src/wav_trim.cpp @@ -0,0 +1,160 @@ +// wav_trim — pure implementation. See wav_trim.h. NO REAPER / SWELL / vendor. + +#include "wav_trim.h" + +#include // 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& b, std::size_t off) { + return static_cast(b[off] | (b[off + 1] << 8)); +} +std::uint32_t readU32LE(const std::vector& b, std::size_t off) { + return static_cast(b[off]) | + (static_cast(b[off + 1]) << 8) | + (static_cast(b[off + 2]) << 16) | + (static_cast(b[off + 3]) << 24); +} + +bool tagEquals(const std::vector& 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& 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 extractFloatFrames(const std::vector& bytes, + const WavLayout& layout, + std::size_t startFrame, + std::size_t frameCount) { + std::vector out; + if (!layout.valid) return out; + + const std::size_t bytesPerFrame = + static_cast(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(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(keptDataBytes); + plan.riffSizeFieldOffset = layout.riffSizeFieldOffset; + // RIFF size counts everything after the 8-byte "RIFF"+size prefix. + plan.newRiffSize = static_cast(plan.newFileByteLength - 8); + return plan; +} + +} // namespace reasampler diff --git a/src/wav_trim.h b/src/wav_trim.h new file mode 100644 index 0000000..7acf5ee --- /dev/null +++ b/src/wav_trim.h @@ -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 +#include +#include + +#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(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& 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 extractFloatFrames(const std::vector& 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 diff --git a/tests/test_peaks.cpp b/tests/test_peaks.cpp index 6567eed..d3b10ab 100644 --- a/tests/test_peaks.cpp +++ b/tests/test_peaks.cpp @@ -278,6 +278,76 @@ static void testLargeBinCountOverflowGuard() { 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 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 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 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 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 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 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 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 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() { testSineEnvelope(); testRampMonotonic(); @@ -289,6 +359,11 @@ int main() { testSingleBinWholeBuffer(); testDegenerateInputs(); testLargeBinCountOverflowGuard(); + testLastFrameDecayingRamp(); + testLastFrameSilence(); + testLastFrameAllAbove(); + testLastFramePerChannelMaxAbs(); + testLastFrameDegenerate(); if (g_fail == 0) std::printf("All tests passed.\n"); return g_fail ? 1 : 0; diff --git a/tests/test_render_settings.cpp b/tests/test_render_settings.cpp index a1727e7..57a30d8 100644 --- a/tests/test_render_settings.cpp +++ b/tests/test_render_settings.cpp @@ -126,6 +126,31 @@ static void testTailManualClampsToCap() { 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 -------------------------- static void testParseSingleTrackAudioArea() { @@ -267,6 +292,9 @@ int main() { testAutoTrimRatioDerivesFromDb(); testTailManualFixedNoTrim(); testTailManualClampsToCap(); + testRealtimeWindowNoneIsExact(); + testRealtimeWindowAutoAddsCap(); + testRealtimeWindowManualAddsClampedLength(); testParseSingleTrackAudioArea(); testParseMultipleAreas(); testParseSkipsEnvelopeLaneAreas(); diff --git a/tests/test_wav_trim.cpp b/tests/test_wav_trim.cpp new file mode 100644 index 0000000..a615be2 --- /dev/null +++ b/tests/test_wav_trim.cpp @@ -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 +#include +#include +#include + +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& b, std::uint16_t v) { + b.push_back(static_cast(v & 0xFF)); + b.push_back(static_cast((v >> 8) & 0xFF)); +} +static void putU32(std::vector& b, std::uint32_t v) { + b.push_back(static_cast(v & 0xFF)); + b.push_back(static_cast((v >> 8) & 0xFF)); + b.push_back(static_cast((v >> 16) & 0xFF)); + b.push_back(static_cast((v >> 24) & 0xFF)); +} +static void putTag(std::vector& b, const char* t) { + for (int i = 0; i < 4; ++i) b.push_back(static_cast(t[i])); +} +static void putFloat(std::vector& 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 +static std::vector 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(frames * channels * (bits / 8)); + + std::vector 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(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 wav; + putTag(wav, "RIFF"); + putU32(wav, static_cast(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 +static std::vector 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(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 fmt; + putU16(fmt, 0xFFFE); // wFormatTag + putU16(fmt, channels); // nChannels + putU32(fmt, sampleRate); // nSamplesPerSec + putU32(fmt, sampleRate * channels * 4u); // nAvgBytesPerSec + putU16(fmt, static_cast(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 chunks; + putTag(chunks, "fmt "); + putU32(chunks, static_cast(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 wav; + putTag(wav, "RIFF"); + putU32(wav, static_cast(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(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(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 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 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(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 trimmed(wav.begin(), + wav.begin() + p.newFileByteLength); + // Patch the two size fields (what the shell does before truncating on disk). + auto writeU32 = [](std::vector& b, std::size_t off, std::uint32_t v) { + b[off + 0] = static_cast(v & 0xFF); + b[off + 1] = static_cast((v >> 8) & 0xFF); + b[off + 2] = static_cast((v >> 16) & 0xFF); + b[off + 3] = static_cast((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(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 trimmed(wav.begin(), wav.begin() + p.newFileByteLength); + auto writeU32 = [](std::vector& b, std::size_t off, std::uint32_t v) { + b[off + 0] = static_cast(v & 0xFF); + b[off + 1] = static_cast((v >> 8) & 0xFF); + b[off + 2] = static_cast((v >> 16) & 0xFF); + b[off + 3] = static_cast((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; +}