Q-W3: main.cpp → pointers+entry+dispatch via 4 capture hoists; one pure wav_codec RIFF owner; ICaptureBackend deleted; capture_realtime rename + finalize split; shared stampCaptureSample; makeUniqueTag gains monotonic counter (fixes same-second batch collisions). 60/60 green.
This commit is contained in:
@@ -85,7 +85,7 @@ There is no hot-reload. Copy the built binary into REAPER's `UserPlugins/` folde
|
||||
- `sample_usage` — instance-usage wire: `UsageRecord`, `planUsagePublish` (fresh/heal/clean-replace/union/remint publish plan), `foldUsageRecords`/`usageHeldPaths` (liveness fold — protect-all when records exist but no instance is live; abort→protect-all on unreadable record), `identityMatches` (ReaSampler 9000 FX identity). REAPER-free, unit-tested. The mirror of `assignment_request` on the instrument→extension direction: the wire format and the two safety-critical decisions (what to write on publish, which records count at prune time) are pure so they are provable without a DAW.
|
||||
|
||||
**REAPER-facing shells:**
|
||||
- `capture` — `ICaptureBackend` interface; `OfflineRenderBackend` (deterministic default) and `RealtimeRecordBackend`. Input: `CaptureRequest`. Output: finished file + populated `Sample` handed to `bank_model`.
|
||||
- `capture` — two CONCRETE backends with deliberately different lifecycles (no shared interface — the former `ICaptureBackend` was deleted in Q-W3, T4-26: one deriver, zero polymorphic call sites): `OfflineRenderBackend` (deterministic default, synchronous) and `RealtimeRecordBackend` (async begin/tick/abort). Input: `CaptureRequest`. Output: finished file + populated `Sample` handed to `bank_model`.
|
||||
- `insert` — placement via `InsertMedia`. **Conform-to-project-tempo is an explicit opt-in flag, never silent stretching.**
|
||||
- `bank_panel` — docked LICE-drawn grid with three-zone layout: top toolbar (Capture → Maintenance → Placement via `action_bar`, short labels, More (⋯) overflow menu via `overflow_menu`), bottom toolbar (four opposite-mode tag buttons + Show Both), and footer (`[Arrange|Design]` toggle, Tail button, Prune via `footer_bar`). Grid renders in sparse slot order with gap cells, drop dispatch, metadata overlay, and selection via `accent/tertiary` purple border. Draws through the L1 kit by palette role; OS drag-out via `drag_out` + `drag_out_win`.
|
||||
- `persist` — project ext state (`SetProjExtState`/`GetProjExtState`, namespace `"reasampler"`) ↔ `BankBook` JSON, `ViewModeModel` JSON, `TailSetting` JSON, `OwnedManifest` JSON, and writing-version stamp. A `projectconfig` hook triggers a deferred session reload on undo/redo. Hosts the prune dry-run and full-set orphan queries; supplies `referencedPaths()` + `owned().paths()` to the `prune_reconcile` pure core. **pS-usage:** `scanPruneOrphans` unions instance usage via `usage_scan`; `PruneReport` gains `abortedUnreadableUsage` + `offendingUsageKeys`; dry-run / orphan-set / reclaim each independently abort (delete nothing) when usage state is unreadable.
|
||||
|
||||
+43
-28
@@ -310,30 +310,40 @@ add_library(prune_button STATIC src/core/ui/prune_button.cpp)
|
||||
target_include_directories(prune_button PUBLIC src)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2g) Pure realtime_record library — NO REAPER, NO SWELL. The M8 realtime-record
|
||||
# logic: capture scope + FX-tap point -> I_RECMODE / I_RECMODE_FLAGS values,
|
||||
# wet/dry -> tap point, and the recorded-file -> Sample mapping. Split out so
|
||||
# the fiddly record-mode bit values + Sample population are unit-tested outside
|
||||
# the DAW; the transport/temp-track/send/file-move recipe stays in capture.cpp.
|
||||
# 2g) Pure capture_realtime library — NO REAPER, NO SWELL. (Renamed from
|
||||
# realtime_record in Q-W3 — the Q-9 naming rider: pure module takes the stem,
|
||||
# the shell takes the suffix, matching drag_out <-> drag_out_win.) The M8
|
||||
# realtime-record logic: capture scope + FX-tap point -> I_RECMODE /
|
||||
# I_RECMODE_FLAGS values, wet/dry -> tap point, the recorded-file -> Sample
|
||||
# mapping, and the async record-phase state machine. Split out so the fiddly
|
||||
# record-mode bit values + Sample population + phase transitions are
|
||||
# unit-tested outside the DAW; the transport/temp-track/send recipe stays in
|
||||
# capture_realtime_shell.cpp (+ the file-side capture_realtime_finalize.cpp).
|
||||
# Depends on bank_model for the pure Sample / SourceMode types.
|
||||
# ---------------------------------------------------------------------------
|
||||
add_library(realtime_record STATIC src/core/capture/realtime_record.cpp)
|
||||
target_include_directories(realtime_record PUBLIC src)
|
||||
target_link_libraries(realtime_record PUBLIC bank_model)
|
||||
add_library(capture_realtime STATIC src/core/capture/capture_realtime.cpp)
|
||||
target_include_directories(capture_realtime PUBLIC src)
|
||||
target_link_libraries(capture_realtime 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.
|
||||
# 2h) Pure wav_codec library — NO REAPER, NO SWELL. The ONE owner of the WAV/RIFF
|
||||
# byte format (Q-W3, audit §4e: T2-08 / T4-10 / T4-23 consolidation): the RIFF
|
||||
# chunk walker + layout parse (formerly wav_trim), the tail-trim truncate plan +
|
||||
# size-field patch (formerly duplicated in capture_realtime), the float32 WAV
|
||||
# build (formerly hand-rolled in ingest), and the WAV-aware content hashes
|
||||
# (formerly in capture_paths). The dedup-by-hash and null-test invariants rest
|
||||
# on this one implementation. File I/O stays in the shells. Depends on peaks
|
||||
# for the AudioSample float alias.
|
||||
# `wav_trim` remains as a TRANSITIONAL alias (forwarding header + INTERFACE
|
||||
# target) so the Q-W2v-owned TUs (sample_map, VST editor/processor) build
|
||||
# untouched in their parallel wave; retire both once Q-W2v lands.
|
||||
# ---------------------------------------------------------------------------
|
||||
add_library(wav_trim STATIC src/core/capture/wav_trim.cpp)
|
||||
target_include_directories(wav_trim PUBLIC src)
|
||||
target_link_libraries(wav_trim PUBLIC peaks)
|
||||
add_library(wav_codec STATIC src/core/capture/wav_codec.cpp)
|
||||
target_include_directories(wav_codec PUBLIC src)
|
||||
target_link_libraries(wav_codec PUBLIC peaks)
|
||||
|
||||
add_library(wav_trim INTERFACE)
|
||||
target_link_libraries(wav_trim INTERFACE wav_codec)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2i) Pure app_version library — NO REAPER, NO SWELL. The Phase V (V1) version-identity
|
||||
@@ -649,9 +659,9 @@ add_executable(tail_control_tests tests/test_tail_control.cpp)
|
||||
target_link_libraries(tail_control_tests PRIVATE tail_control)
|
||||
add_test(NAME tail_control_tests COMMAND tail_control_tests)
|
||||
|
||||
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(capture_realtime_tests tests/test_capture_realtime.cpp)
|
||||
target_link_libraries(capture_realtime_tests PRIVATE capture_realtime)
|
||||
add_test(NAME capture_realtime_tests COMMAND capture_realtime_tests)
|
||||
|
||||
add_executable(bank_book_tests tests/test_bank_book.cpp)
|
||||
target_link_libraries(bank_book_tests PRIVATE bank_book)
|
||||
@@ -661,9 +671,9 @@ add_executable(slot_map_tests tests/test_slot_map.cpp)
|
||||
target_link_libraries(slot_map_tests PRIVATE slot_map json)
|
||||
add_test(NAME slot_map_tests COMMAND slot_map_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)
|
||||
add_executable(wav_codec_tests tests/test_wav_codec.cpp)
|
||||
target_link_libraries(wav_codec_tests PRIVATE wav_codec)
|
||||
add_test(NAME wav_codec_tests COMMAND wav_codec_tests)
|
||||
|
||||
add_executable(owned_manifest_tests tests/test_owned_manifest.cpp)
|
||||
target_link_libraries(owned_manifest_tests PRIVATE owned_manifest)
|
||||
@@ -1083,8 +1093,13 @@ set(LICE_SRC
|
||||
add_library(reaper_reasampler MODULE
|
||||
src/app/main.cpp
|
||||
src/shell/capture/capture.cpp
|
||||
src/shell/capture/capture_realtime.cpp
|
||||
src/core/capture/realtime_record.cpp
|
||||
src/shell/capture/capture_orchestrator.cpp
|
||||
src/shell/capture/capture_batch.cpp
|
||||
src/shell/capture/scope_resolve.cpp
|
||||
src/shell/capture/realtime_lifecycle.cpp
|
||||
src/shell/capture/capture_realtime_shell.cpp
|
||||
src/shell/capture/capture_realtime_finalize.cpp
|
||||
src/core/capture/capture_realtime.cpp
|
||||
src/persist.cpp
|
||||
src/shell/panel/panel_audition.cpp
|
||||
src/shell/panel/panel_bank_ops.cpp
|
||||
@@ -1123,7 +1138,7 @@ add_library(reaper_reasampler MODULE
|
||||
src/core/ui/card_drag.cpp
|
||||
src/shell/persist/usage_scan.cpp
|
||||
)
|
||||
target_link_libraries(reaper_reasampler PRIVATE json wire file_bytes bank_model capture_paths peaks bank_grid mode_switch tab_strip view_mode_model insert_plan render_settings batch_capture tail_control realtime_record bank_book wav_trim owned_manifest prune_reconcile prune_button app_version provenance drag_out instrument_drop theme component_geometry action_bar footer_bar overflow_menu mode_enable tooltip card_meta card_drag assignment_request bank_sync sample_usage)
|
||||
target_link_libraries(reaper_reasampler PRIVATE json wire file_bytes bank_model capture_paths peaks bank_grid mode_switch tab_strip view_mode_model insert_plan render_settings batch_capture tail_control capture_realtime bank_book wav_codec owned_manifest prune_reconcile prune_button app_version provenance drag_out instrument_drop theme component_geometry action_bar footer_bar overflow_menu mode_enable tooltip card_meta card_drag assignment_request bank_sync sample_usage)
|
||||
target_include_directories(reaper_reasampler PRIVATE ${SDK_INC} ${WDL_INC})
|
||||
# OUTPUT_NAME is channel-derived (Phase V, V4): "reaper_reasampler" (stable, default) or
|
||||
# "reaper_reasampler_beta" (beta). REAPER dlopen's any reaper_* module, so both channels'
|
||||
|
||||
+4
-2
@@ -25,8 +25,10 @@ Pure (no REAPER types, fully unit-tested):
|
||||
format, so this is simpler, testable, and dependency-free.
|
||||
|
||||
REAPER-facing:
|
||||
- `capture` — the `ICaptureBackend` interface plus `OfflineRenderBackend` and
|
||||
`RealtimeRecordBackend`. Input: a `CaptureRequest` (capture scope — item or
|
||||
- `capture` — two CONCRETE backends, `OfflineRenderBackend` (synchronous) and
|
||||
`RealtimeRecordBackend` (async begin/tick/abort); no shared interface (the
|
||||
former `ICaptureBackend` was deleted in Q-W3 — T4-26: one deriver, zero
|
||||
polymorphic call sites). Input: a `CaptureRequest` (capture scope — item or
|
||||
track, time range, tail, SR/bit-depth/channels, output path). Output: a finished
|
||||
file + a populated `Sample` handed to `bank_model`. Capture is always wet; the FX
|
||||
*scope* (not a wet/dry dial) is the control — the pure `render_settings` module
|
||||
|
||||
+55
-1301
File diff suppressed because it is too large
Load Diff
@@ -2,119 +2,13 @@
|
||||
|
||||
#include <cassert>
|
||||
#include <cctype>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <cstring> // std::memcmp
|
||||
#include <filesystem>
|
||||
#include <vector>
|
||||
|
||||
namespace reasampler::capture {
|
||||
|
||||
std::string hashBytes(const std::uint8_t* data, std::size_t len) {
|
||||
// FNV-1a 64-bit: deterministic, no dependencies, adequate for dedup identity.
|
||||
// Constants from the FNV spec (http://www.isthe.com/chongo/tech/comp/fnv/).
|
||||
constexpr std::uint64_t kOffsetBasis = 14695981039346656037ULL;
|
||||
constexpr std::uint64_t kPrime = 1099511628211ULL;
|
||||
std::uint64_t h = kOffsetBasis;
|
||||
for (std::size_t i = 0; i < len; ++i) {
|
||||
h ^= static_cast<std::uint64_t>(data[i]);
|
||||
h *= kPrime;
|
||||
}
|
||||
// Format as 16-digit lowercase hex (zero-padded) for a fixed-length string.
|
||||
char buf[17];
|
||||
std::snprintf(buf, sizeof(buf), "%016llx",
|
||||
static_cast<unsigned long long>(h));
|
||||
return std::string(buf);
|
||||
}
|
||||
|
||||
std::string hashWavContent(const std::vector<std::uint8_t>& bytes) {
|
||||
// Walk the RIFF/WAVE container and feed only the `fmt ` body and `data` body
|
||||
// through FNV-1a, prefixed with the domain-separation tag byte 'W' (0x57).
|
||||
// Any render-varying metadata chunks (bext, iXML, LIST, SMED, etc.) are skipped.
|
||||
// If the file does not parse as RIFF/WAVE with both fmt and data chunks, fall back
|
||||
// to whole-file hashBytes (no prefix) so an unrecognized file still gets a hash.
|
||||
//
|
||||
// The chunk-walk mirrors wav_trim::parseWavLayout's structure but accumulates
|
||||
// FNV state instead of recording geometry — no second parser, same logic.
|
||||
|
||||
// FNV-1a 64-bit constants (same as hashBytes).
|
||||
constexpr std::uint64_t kOffsetBasis = 14695981039346656037ULL;
|
||||
constexpr std::uint64_t kPrime = 1099511628211ULL;
|
||||
|
||||
// Minimum viable RIFF/WAVE: "RIFF"(4) size(4) "WAVE"(4) = 12 bytes.
|
||||
auto tagEq = [&](std::size_t off, const char* tag) -> bool {
|
||||
return off + 4 <= bytes.size() &&
|
||||
std::memcmp(bytes.data() + off, tag, 4) == 0;
|
||||
};
|
||||
auto readU32LE = [&](std::size_t off) -> std::uint32_t {
|
||||
return static_cast<std::uint32_t>(bytes[off]) |
|
||||
(static_cast<std::uint32_t>(bytes[off + 1]) << 8) |
|
||||
(static_cast<std::uint32_t>(bytes[off + 2]) << 16) |
|
||||
(static_cast<std::uint32_t>(bytes[off + 3]) << 24);
|
||||
};
|
||||
|
||||
bool isWav = bytes.size() >= 12 &&
|
||||
tagEq(0, "RIFF") &&
|
||||
tagEq(8, "WAVE");
|
||||
|
||||
if (isWav) {
|
||||
// Accumulate FNV-1a starting with the domain-separation tag byte 'W'.
|
||||
std::uint64_t h = kOffsetBasis;
|
||||
auto feedByte = [&](std::uint8_t b) {
|
||||
h ^= static_cast<std::uint64_t>(b);
|
||||
h *= kPrime;
|
||||
};
|
||||
|
||||
bool haveFmt = false;
|
||||
bool haveData = false;
|
||||
|
||||
// Domain-separation prefix: 'W' (0x57) distinguishes a content hash from a
|
||||
// whole-file hash of different bytes that happen to be the same length.
|
||||
feedByte(static_cast<std::uint8_t>('W'));
|
||||
|
||||
std::size_t pos = 12;
|
||||
while (pos + 8 <= bytes.size()) {
|
||||
const std::size_t bodyOffset = pos + 8;
|
||||
const std::uint32_t bodySize = readU32LE(pos + 4);
|
||||
|
||||
if (tagEq(pos, "fmt ")) {
|
||||
// Feed the entire fmt body (all fields, including format tag, channels,
|
||||
// sample rate, bits-per-sample — everything that defines the audio format).
|
||||
if (bodyOffset + bodySize <= bytes.size()) {
|
||||
for (std::uint32_t i = 0; i < bodySize; ++i)
|
||||
feedByte(bytes[bodyOffset + i]);
|
||||
haveFmt = true;
|
||||
}
|
||||
} else if (tagEq(pos, "data")) {
|
||||
// Feed the entire PCM payload.
|
||||
if (bodyOffset + bodySize <= bytes.size()) {
|
||||
for (std::uint32_t i = 0; i < bodySize; ++i)
|
||||
feedByte(bytes[bodyOffset + i]);
|
||||
haveData = true;
|
||||
}
|
||||
}
|
||||
// All other chunks (bext, iXML, LIST, SMED, cue, etc.) are skipped.
|
||||
|
||||
// Advance past this chunk's body, honoring RIFF even-byte padding.
|
||||
std::size_t advance = bodySize;
|
||||
if (advance & 1u) ++advance; // RIFF pad byte
|
||||
if (advance > bytes.size() - bodyOffset) break; // overrun guard
|
||||
pos = bodyOffset + advance;
|
||||
}
|
||||
|
||||
if (haveFmt && haveData) {
|
||||
char buf[17];
|
||||
std::snprintf(buf, sizeof(buf), "%016llx",
|
||||
static_cast<unsigned long long>(h));
|
||||
return std::string(buf);
|
||||
}
|
||||
// Falls through to whole-file fallback if chunks were missing/malformed.
|
||||
}
|
||||
|
||||
// Fallback: not a parseable RIFF/WAVE — hash the whole file (same as the old
|
||||
// per-call hashBytes). No prefix tag: identical to hashBytes(data, size).
|
||||
return hashBytes(bytes.data(), bytes.size());
|
||||
}
|
||||
// The content-identity hashes (hashBytes / hashWavContent) moved to wav_codec
|
||||
// (Q-W3, audit §4e) — one pure owner of the RIFF chunk walk, shared with the
|
||||
// layout parse so hashing and decoding cannot desynchronize.
|
||||
|
||||
std::string normalizeSlashes(const std::string& path) {
|
||||
std::string out = path;
|
||||
|
||||
@@ -34,37 +34,9 @@ struct BankPaths {
|
||||
std::string fileStem; // <stem> (RENDER_PATTERN — REAPER appends the extension)
|
||||
};
|
||||
|
||||
// Computes a deterministic FNV-1a 64-bit content hash over `len` bytes at `data`
|
||||
// and returns it as a 16-character lowercase hex string. Designed to fill
|
||||
// Sample::contentHash so the confirm-on-last-reference guardrail
|
||||
// (BankBook::hashReferencedElsewhere) can distinguish "no other bank holds this
|
||||
// file" from "another bank holds the same file." An empty buffer returns the bare
|
||||
// FNV-1a 64-bit offset basis in hex (a stable, non-empty sentinel that two empty
|
||||
// files would share, but real WAV files are never empty).
|
||||
std::string hashBytes(const std::uint8_t* data, std::size_t len);
|
||||
|
||||
// WAV-aware content hash: hashes only the audio-defining content of a 32-bit-float
|
||||
// RIFF/WAVE file — the `fmt ` chunk body + the `data` chunk payload — skipping all
|
||||
// other RIFF chunks (e.g. `bext` origination timestamp, `iXML`, `LIST`/`INFO`, SMED).
|
||||
//
|
||||
// WHY: REAPER's offline renderer embeds render-varying metadata chunks (at minimum a
|
||||
// `bext` chunk containing the origination date/time) even when the format config blob
|
||||
// requests no BWF metadata. Two renders of identical audio therefore differ in those
|
||||
// bytes, making whole-file hashes diverge and preventing dedup collapse.
|
||||
//
|
||||
// DOMAIN SEPARATION: the FNV-1a input is prefixed with the tag byte 'W' (0x57) before
|
||||
// the fmt/data bytes are fed in, so a content hash can never equal a whole-file
|
||||
// hashBytes result for a different file of the same size.
|
||||
//
|
||||
// FALLBACK: if `bytes` does not parse as a valid RIFF/WAVE with both a `fmt ` and a
|
||||
// `data` chunk, the function falls back to whole-file hashBytes (no prefix tag) —
|
||||
// identical to calling hashBytes(bytes.data(), bytes.size()). This ensures that an
|
||||
// unrecognized or malformed file still gets a non-empty hash rather than silently
|
||||
// skipping dedup.
|
||||
//
|
||||
// Called by both capture commit paths (offline and realtime) in place of the raw
|
||||
// hashBytes call.
|
||||
std::string hashWavContent(const std::vector<std::uint8_t>& bytes);
|
||||
// NOTE (Q-W3, audit §4e): the content-identity hashes (hashBytes / hashWavContent)
|
||||
// moved to core/capture/wav_codec.{h,cpp} — the ONE pure owner of the WAV/RIFF byte
|
||||
// format — so this module holds path arithmetic only, with no RIFF chunk knowledge.
|
||||
|
||||
// Normalizes a path to forward slashes and strips any trailing slash. Empty in
|
||||
// -> empty out. Pure string transform (does not consult the filesystem).
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
// realtime_record.cpp — pure logic for the realtime-record backend (M8). See header.
|
||||
// NO REAPER types; unit-tested by tests/test_realtime_record.cpp.
|
||||
// capture_realtime.cpp — pure logic for the realtime-record backend (M8). See
|
||||
// header. NO REAPER types; unit-tested by tests/test_capture_realtime.cpp.
|
||||
// (Renamed from realtime_record.cpp in Q-W3 — the Q-9 naming rider.)
|
||||
|
||||
#include "core/capture/realtime_record.h"
|
||||
#include "core/capture/capture_realtime.h"
|
||||
|
||||
namespace reasampler::capture {
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
#pragma once
|
||||
// realtime_record — the REAPER-free logic behind the realtime-record backend (M8).
|
||||
// capture_realtime — the REAPER-free logic behind the realtime-record backend (M8).
|
||||
// (Renamed from realtime_record in Q-W3 — the Q-9 naming rider: the PURE module
|
||||
// takes the stem, the shell takes the suffix — capture_realtime_shell.cpp /
|
||||
// capture_realtime_finalize.cpp — matching the drag_out ↔ drag_out_win model.)
|
||||
//
|
||||
// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO
|
||||
// vendor/ includes. Standard library only. The realtime backend (capture.cpp)
|
||||
// drives the transport, the temp track, the send routing, and the file move —
|
||||
// vendor/ includes. Standard library only. The realtime shell drives the
|
||||
// transport, the temp track, the send routing, and the file move —
|
||||
// all REAPER-bound and DAW-verified. The genuinely pure, easy-to-get-wrong
|
||||
// pieces are split out here and unit-tested outside the DAW:
|
||||
//
|
||||
@@ -0,0 +1,333 @@
|
||||
// wav_codec — pure implementation. See wav_codec.h. NO REAPER / SWELL / vendor.
|
||||
//
|
||||
// The ONE RIFF chunk traversal lives here (nextWavChunk); the layout parse and the
|
||||
// content hash both walk with it, so their view of the container cannot drift.
|
||||
|
||||
#include "core/capture/wav_codec.h"
|
||||
|
||||
#include <cstdio> // std::snprintf (hash hex render)
|
||||
#include <cstring> // std::memcpy, std::memcmp
|
||||
|
||||
namespace reasampler::capture {
|
||||
|
||||
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_codec.h FORMAT ASSUMPTION).
|
||||
constexpr std::uint16_t kWaveFormatIeeeFloat = 0x0003;
|
||||
constexpr std::uint16_t kWaveFormatExtensible = 0xFFFE;
|
||||
|
||||
// FNV-1a 64-bit constants (http://www.isthe.com/chongo/tech/comp/fnv/).
|
||||
constexpr std::uint64_t kFnvOffsetBasis = 14695981039346656037ULL;
|
||||
constexpr std::uint64_t kFnvPrime = 1099511628211ULL;
|
||||
|
||||
std::string fnvHex(std::uint64_t h) {
|
||||
// 16-digit lowercase hex (zero-padded) for a fixed-length string.
|
||||
char buf[17];
|
||||
std::snprintf(buf, sizeof(buf), "%016llx", static_cast<unsigned long long>(h));
|
||||
return std::string(buf);
|
||||
}
|
||||
|
||||
// --- The ONE RIFF chunk traversal --------------------------------------------
|
||||
//
|
||||
// One sub-chunk of a RIFF/WAVE container as the walk sees it: header at
|
||||
// `headerOffset` (id(4) + size(4)), body at `bodyOffset` with declared `bodySize`.
|
||||
// `bodyInBounds` is whether the declared body fits inside the buffer — a chunk
|
||||
// whose declared size lies past the end is still REPORTED (callers decide how to
|
||||
// treat it) but its body must not be read.
|
||||
struct WavChunkView {
|
||||
std::size_t headerOffset = 0;
|
||||
std::size_t bodyOffset = 0;
|
||||
std::uint32_t bodySize = 0;
|
||||
bool bodyInBounds = false;
|
||||
};
|
||||
|
||||
// Advances one chunk. `pos` starts at 12 (after "RIFF" size "WAVE"); each call
|
||||
// fills `out` and moves `pos` past the chunk's body, honoring RIFF even-byte
|
||||
// padding. Returns false when no further chunk header fits. If the padded advance
|
||||
// would overrun the buffer, the chunk is still reported (return true) and `pos` is
|
||||
// parked past the end so the NEXT call returns false — exactly the process-then-
|
||||
// break shape the pre-consolidation walkers shared.
|
||||
bool nextWavChunk(const std::vector<std::uint8_t>& bytes, std::size_t& pos,
|
||||
WavChunkView& out) {
|
||||
if (pos + 8 > bytes.size()) return false;
|
||||
|
||||
out.headerOffset = pos;
|
||||
out.bodyOffset = pos + 8;
|
||||
out.bodySize = readU32LE(bytes, pos + 4);
|
||||
out.bodyInBounds = (out.bodyOffset + out.bodySize <= bytes.size());
|
||||
|
||||
std::size_t advance = out.bodySize;
|
||||
if (advance & 1u) ++advance; // RIFF pad byte
|
||||
if (advance > bytes.size() - out.bodyOffset) {
|
||||
pos = bytes.size(); // overrun -> this is the last reported chunk
|
||||
} else {
|
||||
pos = out.bodyOffset + advance;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool isRiffWave(const std::vector<std::uint8_t>& bytes) {
|
||||
// Minimum viable RIFF/WAVE: "RIFF"(4) size(4) "WAVE"(4) = 12 bytes.
|
||||
return bytes.size() >= 12 && tagEquals(bytes, 0, "RIFF") &&
|
||||
tagEquals(bytes, 8, "WAVE");
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
WavLayout parseWavLayout(const std::vector<std::uint8_t>& bytes) {
|
||||
WavLayout out;
|
||||
|
||||
if (!isRiffWave(bytes)) 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) with the shared traversal. A
|
||||
// malformed/truncated file is "invalid", never an OOB read.
|
||||
std::size_t pos = 12;
|
||||
WavChunkView c;
|
||||
while (nextWavChunk(bytes, pos, c)) {
|
||||
if (tagEquals(bytes, c.headerOffset, "fmt ")) {
|
||||
// fmt body: at least 16 bytes (PCM/float common fields).
|
||||
if (c.bodyOffset + 16 > bytes.size() || c.bodySize < 16) return out;
|
||||
fmtTag = readU16LE(bytes, c.bodyOffset + 0);
|
||||
channels = readU16LE(bytes, c.bodyOffset + 2);
|
||||
sampleRate = readU32LE(bytes, c.bodyOffset + 4);
|
||||
bitsPerSample = readU16LE(bytes, c.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 (c.bodySize >= 40 && c.bodyOffset + 40 <= bytes.size()) {
|
||||
extensibleSubFormatTag = readU16LE(bytes, c.bodyOffset + 24);
|
||||
}
|
||||
}
|
||||
haveFmt = true;
|
||||
} else if (tagEquals(bytes, c.headerOffset, "data")) {
|
||||
// The data chunk: PCM starts at bodyOffset, declared length bodySize.
|
||||
// Reject if it runs past the buffer (truncated / lying header).
|
||||
if (!c.bodyInBounds) 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 = c.bodyOffset;
|
||||
out.dataByteLength = c.bodySize;
|
||||
out.riffSizeFieldOffset = 4;
|
||||
out.dataSizeFieldOffset = c.headerOffset + 4; // the `data` size field (LE uint32)
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
void patchU32LE(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);
|
||||
}
|
||||
|
||||
std::vector<std::uint8_t> buildFloat32Wav(int nch, std::uint32_t rate,
|
||||
std::size_t frameCount,
|
||||
const std::vector<double>& interleaved) {
|
||||
const std::size_t sampleCount = frameCount * static_cast<std::size_t>(nch);
|
||||
const std::size_t dataBytesCount = sampleCount * 4u; // 4 bytes per float32
|
||||
|
||||
// The WAV is: RIFF(4)+size(4)+WAVE(4) = 12, fmt (4)+size(4)+16 body = 24, data (4)+size(4)+payload.
|
||||
// Total = 12 + 24 + 8 + dataBytesCount = 44 + dataBytesCount.
|
||||
const std::uint32_t riffSize =
|
||||
static_cast<std::uint32_t>(36u + dataBytesCount); // 4("WAVE")+24(fmt chunk)+8(data hdr)+data
|
||||
|
||||
std::vector<std::uint8_t> out;
|
||||
out.reserve(44u + dataBytesCount);
|
||||
|
||||
auto putU16 = [&](std::uint16_t v) {
|
||||
out.push_back(static_cast<std::uint8_t>(v & 0xFF));
|
||||
out.push_back(static_cast<std::uint8_t>((v >> 8) & 0xFF));
|
||||
};
|
||||
auto putU32 = [&](std::uint32_t v) {
|
||||
out.push_back(static_cast<std::uint8_t>(v & 0xFF));
|
||||
out.push_back(static_cast<std::uint8_t>((v >> 8) & 0xFF));
|
||||
out.push_back(static_cast<std::uint8_t>((v >> 16) & 0xFF));
|
||||
out.push_back(static_cast<std::uint8_t>((v >> 24) & 0xFF));
|
||||
};
|
||||
auto putTag = [&](const char* t) {
|
||||
for (int i = 0; i < 4; ++i)
|
||||
out.push_back(static_cast<std::uint8_t>(t[i]));
|
||||
};
|
||||
auto putF32 = [&](float f) {
|
||||
std::uint8_t tmp[4];
|
||||
std::memcpy(tmp, &f, 4);
|
||||
for (int i = 0; i < 4; ++i) out.push_back(tmp[i]);
|
||||
};
|
||||
|
||||
// RIFF header
|
||||
putTag("RIFF");
|
||||
putU32(riffSize);
|
||||
putTag("WAVE");
|
||||
|
||||
// fmt chunk (16-byte body, WAVE_FORMAT_IEEE_FLOAT = 0x0003)
|
||||
putTag("fmt ");
|
||||
putU32(16u); // chunk body size
|
||||
putU16(0x0003u); // WAVE_FORMAT_IEEE_FLOAT
|
||||
putU16(static_cast<std::uint16_t>(nch));
|
||||
putU32(rate);
|
||||
putU32(rate * static_cast<std::uint32_t>(nch) * 4u); // avgBytesPerSec
|
||||
putU16(static_cast<std::uint16_t>(nch * 4)); // blockAlign
|
||||
putU16(32u); // bitsPerSample
|
||||
|
||||
// data chunk
|
||||
putTag("data");
|
||||
putU32(static_cast<std::uint32_t>(dataBytesCount));
|
||||
for (std::size_t i = 0; i < sampleCount && i < interleaved.size(); ++i)
|
||||
putF32(static_cast<float>(interleaved[i]));
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
std::string hashBytes(const std::uint8_t* data, std::size_t len) {
|
||||
// FNV-1a 64-bit: deterministic, no dependencies, adequate for dedup identity.
|
||||
std::uint64_t h = kFnvOffsetBasis;
|
||||
for (std::size_t i = 0; i < len; ++i) {
|
||||
h ^= static_cast<std::uint64_t>(data[i]);
|
||||
h *= kFnvPrime;
|
||||
}
|
||||
return fnvHex(h);
|
||||
}
|
||||
|
||||
std::string hashWavContent(const std::vector<std::uint8_t>& bytes) {
|
||||
// Walk the RIFF/WAVE container (the shared traversal) and feed only the `fmt `
|
||||
// body and `data` body through FNV-1a, prefixed with the domain-separation tag
|
||||
// byte 'W' (0x57). Any render-varying metadata chunks (bext, iXML, LIST, SMED,
|
||||
// etc.) are skipped. If the file does not parse as RIFF/WAVE with both fmt and
|
||||
// data chunks, fall back to whole-file hashBytes (no prefix) so an unrecognized
|
||||
// file still gets a hash.
|
||||
if (isRiffWave(bytes)) {
|
||||
std::uint64_t h = kFnvOffsetBasis;
|
||||
auto feedByte = [&](std::uint8_t b) {
|
||||
h ^= static_cast<std::uint64_t>(b);
|
||||
h *= kFnvPrime;
|
||||
};
|
||||
|
||||
bool haveFmt = false;
|
||||
bool haveData = false;
|
||||
|
||||
// Domain-separation prefix: 'W' (0x57) distinguishes a content hash from a
|
||||
// whole-file hash of different bytes that happen to be the same length.
|
||||
feedByte(static_cast<std::uint8_t>('W'));
|
||||
|
||||
std::size_t pos = 12;
|
||||
WavChunkView c;
|
||||
while (nextWavChunk(bytes, pos, c)) {
|
||||
if (tagEquals(bytes, c.headerOffset, "fmt ")) {
|
||||
// Feed the entire fmt body (all fields, including format tag, channels,
|
||||
// sample rate, bits-per-sample — everything that defines the audio format).
|
||||
if (c.bodyInBounds) {
|
||||
for (std::uint32_t i = 0; i < c.bodySize; ++i)
|
||||
feedByte(bytes[c.bodyOffset + i]);
|
||||
haveFmt = true;
|
||||
}
|
||||
} else if (tagEquals(bytes, c.headerOffset, "data")) {
|
||||
// Feed the entire PCM payload.
|
||||
if (c.bodyInBounds) {
|
||||
for (std::uint32_t i = 0; i < c.bodySize; ++i)
|
||||
feedByte(bytes[c.bodyOffset + i]);
|
||||
haveData = true;
|
||||
}
|
||||
}
|
||||
// All other chunks (bext, iXML, LIST, SMED, cue, etc.) are skipped.
|
||||
}
|
||||
|
||||
if (haveFmt && haveData) return fnvHex(h);
|
||||
// Falls through to whole-file fallback if chunks were missing/malformed.
|
||||
}
|
||||
|
||||
// Fallback: not a parseable RIFF/WAVE — hash the whole file (identical to
|
||||
// hashBytes(data, size); no prefix tag).
|
||||
return hashBytes(bytes.data(), bytes.size());
|
||||
}
|
||||
|
||||
} // namespace reasampler::capture
|
||||
@@ -0,0 +1,172 @@
|
||||
#pragma once
|
||||
// wav_codec — the ONE pure owner of the WAV/RIFF byte format (Q-W3, audit §4e:
|
||||
// T2-08 / T4-10 / T4-23 consolidation). Chunk walker + layout parse + float32
|
||||
// build + size-field patch + the WAV-aware content hash, in one tested module.
|
||||
//
|
||||
// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO
|
||||
// vendor/ includes. Standard library only. Builds and unit-tests without REAPER.
|
||||
//
|
||||
// Before this module, RIFF container knowledge (chunk-header arithmetic, even-byte
|
||||
// padding, size fields) was minted at four sites: wav_trim's layout parse,
|
||||
// capture_paths' content-hash chunk walk, ingest's hand-built float32 writer, and
|
||||
// capture_realtime's in-place size patch. A drift in any one (e.g. pad-byte
|
||||
// handling) would desynchronize hashing from decoding — the dedup-by-hash and
|
||||
// null-test invariants both sit on this. Now every walker/builder/patcher is here,
|
||||
// on ONE chunk-traversal implementation.
|
||||
//
|
||||
// WHY TRIM 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 does only the file I/O: read the bytes, call the pure parse, run the decay
|
||||
// scan, call the pure plan, patch + 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). The 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 <string>
|
||||
#include <vector>
|
||||
|
||||
#include "core/audio/peaks.h" // AudioSample (float)
|
||||
|
||||
namespace reasampler::capture {
|
||||
|
||||
using audio::AudioSample;
|
||||
|
||||
// --- Layout parse ------------------------------------------------------------
|
||||
|
||||
// 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);
|
||||
|
||||
// --- Truncate plan + size-field patch ---------------------------------------
|
||||
|
||||
// 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
|
||||
// (patchU32LE), then truncate the file to newFileByteLength.
|
||||
WavTruncatePlan planWavTruncate(const WavLayout& layout, std::size_t keptFrames);
|
||||
|
||||
// Patches a little-endian uint32 into a byte buffer at `off` — the RIFF/data size
|
||||
// fields the truncate plan names. The caller guarantees off + 4 <= bytes.size()
|
||||
// (the plan's offsets came from a valid parse of the same buffer).
|
||||
void patchU32LE(std::vector<std::uint8_t>& bytes, std::size_t off, std::uint32_t v);
|
||||
|
||||
// --- Float32 WAV build -------------------------------------------------------
|
||||
|
||||
// Builds a minimal canonical 32-bit-float RIFF/WAVE byte buffer from interleaved
|
||||
// double samples: RIFF chunk, WAVE form, fmt chunk (tag 3 = WAVE_FORMAT_IEEE_FLOAT,
|
||||
// 16-byte body), data chunk (interleaved little-endian float32). `nch` channels,
|
||||
// `rate` Hz, `frameCount` frames (total samples = frameCount * nch). Each double is
|
||||
// narrowed to float by cast — the bank contract is 32-bit float (see FORMAT
|
||||
// ASSUMPTION above); the reduction is intentional. The output round-trips through
|
||||
// parseWavLayout/extractFloatFrames. The ingest shell decodes any non-canonical
|
||||
// source through REAPER's PCM_source, then writes the bank copy with this.
|
||||
std::vector<std::uint8_t> buildFloat32Wav(int nch, std::uint32_t rate,
|
||||
std::size_t frameCount,
|
||||
const std::vector<double>& interleaved);
|
||||
|
||||
// --- Content identity (dedup hashes) -----------------------------------------
|
||||
|
||||
// Computes a deterministic FNV-1a 64-bit content hash over `len` bytes at `data`
|
||||
// and returns it as a 16-character lowercase hex string. Designed to fill
|
||||
// Sample::contentHash so the confirm-on-last-reference guardrail
|
||||
// (BankBook::hashReferencedElsewhere) can distinguish "no other bank holds this
|
||||
// file" from "another bank holds the same file." An empty buffer returns the bare
|
||||
// FNV-1a 64-bit offset basis in hex (a stable, non-empty sentinel that two empty
|
||||
// files would share, but real WAV files are never empty).
|
||||
std::string hashBytes(const std::uint8_t* data, std::size_t len);
|
||||
|
||||
// WAV-aware content hash: hashes only the audio-defining content of a 32-bit-float
|
||||
// RIFF/WAVE file — the `fmt ` chunk body + the `data` chunk payload — skipping all
|
||||
// other RIFF chunks (e.g. `bext` origination timestamp, `iXML`, `LIST`/`INFO`, SMED).
|
||||
//
|
||||
// WHY: REAPER's offline renderer embeds render-varying metadata chunks (at minimum a
|
||||
// `bext` chunk containing the origination date/time) even when the format config blob
|
||||
// requests no BWF metadata. Two renders of identical audio therefore differ in those
|
||||
// bytes, making whole-file hashes diverge and preventing dedup collapse.
|
||||
//
|
||||
// DOMAIN SEPARATION: the FNV-1a input is prefixed with the tag byte 'W' (0x57) before
|
||||
// the fmt/data bytes are fed in, so a content hash can never equal a whole-file
|
||||
// hashBytes result for a different file of the same size.
|
||||
//
|
||||
// FALLBACK: if `bytes` does not parse as a valid RIFF/WAVE with both a `fmt ` and a
|
||||
// `data` chunk, the function falls back to whole-file hashBytes (no prefix tag) —
|
||||
// identical to calling hashBytes(bytes.data(), bytes.size()). This ensures that an
|
||||
// unrecognized or malformed file still gets a non-empty hash rather than silently
|
||||
// skipping dedup.
|
||||
//
|
||||
// Called by both capture commit paths (offline and realtime) and the ingest import
|
||||
// in place of the raw hashBytes call. Walks the container with the SAME chunk
|
||||
// traversal parseWavLayout uses, so hashing and decoding can never desynchronize.
|
||||
std::string hashWavContent(const std::vector<std::uint8_t>& bytes);
|
||||
|
||||
} // namespace reasampler::capture
|
||||
@@ -1,160 +0,0 @@
|
||||
// wav_trim — pure implementation. See wav_trim.h. NO REAPER / SWELL / vendor.
|
||||
|
||||
#include "core/capture/wav_trim.h"
|
||||
|
||||
#include <cstring> // std::memcpy, std::memcmp
|
||||
|
||||
namespace reasampler::capture {
|
||||
|
||||
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::capture
|
||||
+11
-99
@@ -1,103 +1,15 @@
|
||||
#pragma once
|
||||
// wav_trim — pure parse + truncate-plan for the realtime tail's PCM decay-scan trim.
|
||||
// wav_trim — TRANSITIONAL forwarding header (Q-W3, audit §4e WAV/RIFF consolidation).
|
||||
//
|
||||
// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO
|
||||
// vendor/ includes. Standard library only. Builds and unit-tests without REAPER.
|
||||
// The one pure owner of the WAV/RIFF byte format is now core/capture/wav_codec.{h,cpp}
|
||||
// (chunk walker + layout parse + float32 build + size-field patch + content hash).
|
||||
// Everything this header used to declare (WavLayout / parseWavLayout /
|
||||
// extractFloatFrames / WavTruncatePlan / planWavTruncate) lives there, same
|
||||
// namespace (reasampler::capture), same signatures — this include is a pure alias.
|
||||
//
|
||||
// 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.
|
||||
// Kept ONLY so the TUs a parallel wave owns (sample_map.h and the VST editor/
|
||||
// processor god-TUs, Q-W2v) compile untouched — editing them here would collide
|
||||
// with that wave's in-flight split. Retire this header (and point its includers at
|
||||
// wav_codec.h) once Q-W2v lands.
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <vector>
|
||||
|
||||
#include "core/audio/peaks.h" // AudioSample (float)
|
||||
|
||||
namespace reasampler::capture {
|
||||
|
||||
using audio::AudioSample;
|
||||
|
||||
// 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::capture
|
||||
#include "core/capture/wav_codec.h"
|
||||
|
||||
+7
-67
@@ -22,13 +22,13 @@
|
||||
#include "core/model/bank_book.h" // BankBook, Bank, activeBankId / activeIndex
|
||||
#include "core/model/bank_model.h" // Sample, AddResult, findByHash
|
||||
#include "shell/panel/panel_input.h" // bankPanelRefresh
|
||||
#include "core/capture/capture_paths.h" // deriveBankPaths / projectDirOfRpp / hashWavContent
|
||||
#include "core/capture/capture_paths.h" // deriveBankPaths / projectDirOfRpp
|
||||
#include "core/util/file_bytes.h" // shared whole-file loader (Q-W1, T2-03)
|
||||
#include "core/wire/instrument_drop.h" // pure buildInstrumentDropPreset (.vstpreset image for a sampleId)
|
||||
#include "shell/actions/instrument_drop_win.h" // shell loadInstrumentOntoTrack (FX add+apply, no own undo block)
|
||||
#include "persist.h" // ReaSamplerSession
|
||||
|
||||
#include "core/capture/wav_trim.h" // parseWavLayout — 32f-float WAV validator for the fast path
|
||||
#include "core/capture/wav_codec.h" // parseWavLayout (32f fast-path validator), buildFloat32Wav, hashWavContent
|
||||
|
||||
#include "reaper_plugin.h" // reaper_plugin_info_t, gaccel_register_t (full defs)
|
||||
|
||||
@@ -96,71 +96,11 @@ bool writeFileBytes(const std::string& path, const std::vector<std::uint8_t>& by
|
||||
return f.good();
|
||||
}
|
||||
|
||||
// Builds a minimal 32-bit-float RIFF/WAVE byte buffer from interleaved double samples.
|
||||
// The output is a canonical WAV the bank and wav_trim can read:
|
||||
// RIFF chunk, WAVE form, fmt chunk (tag 3 = WAVE_FORMAT_IEEE_FLOAT, 16-byte body),
|
||||
// data chunk (interleaved little-endian float32, one float per sample per channel).
|
||||
// `nch` channels, `rate` Hz sample rate, `frameCount` frames (total samples = frameCount*nch).
|
||||
// Each ReaSample (double) is narrowed to float by assignment — the instrument expects
|
||||
// 32-bit float; the reduction is intentional and matches how the bank contract is defined
|
||||
// (capture.cpp kRenderFormatWavFloat32; wav_trim.h FORMAT ASSUMPTION).
|
||||
std::vector<std::uint8_t> buildFloat32Wav(int nch, std::uint32_t rate,
|
||||
std::size_t frameCount,
|
||||
const std::vector<ReaSample>& interleaved) {
|
||||
const std::size_t sampleCount = frameCount * static_cast<std::size_t>(nch);
|
||||
const std::size_t dataBytesCount = sampleCount * 4u; // 4 bytes per float32
|
||||
|
||||
// The WAV is: RIFF(4)+size(4)+WAVE(4) = 12, fmt (4)+size(4)+16 body = 24, data (4)+size(4)+payload.
|
||||
// Total = 12 + 24 + 8 + dataBytesCount = 44 + dataBytesCount.
|
||||
const std::uint32_t riffSize =
|
||||
static_cast<std::uint32_t>(36u + dataBytesCount); // 4("WAVE")+24(fmt chunk)+8(data hdr)+data
|
||||
|
||||
std::vector<std::uint8_t> out;
|
||||
out.reserve(44u + dataBytesCount);
|
||||
|
||||
auto putU16 = [&](std::uint16_t v) {
|
||||
out.push_back(static_cast<std::uint8_t>(v & 0xFF));
|
||||
out.push_back(static_cast<std::uint8_t>((v >> 8) & 0xFF));
|
||||
};
|
||||
auto putU32 = [&](std::uint32_t v) {
|
||||
out.push_back(static_cast<std::uint8_t>(v & 0xFF));
|
||||
out.push_back(static_cast<std::uint8_t>((v >> 8) & 0xFF));
|
||||
out.push_back(static_cast<std::uint8_t>((v >> 16) & 0xFF));
|
||||
out.push_back(static_cast<std::uint8_t>((v >> 24) & 0xFF));
|
||||
};
|
||||
auto putTag = [&](const char* t) {
|
||||
for (int i = 0; i < 4; ++i)
|
||||
out.push_back(static_cast<std::uint8_t>(t[i]));
|
||||
};
|
||||
auto putF32 = [&](float f) {
|
||||
std::uint8_t tmp[4];
|
||||
std::memcpy(tmp, &f, 4);
|
||||
for (int i = 0; i < 4; ++i) out.push_back(tmp[i]);
|
||||
};
|
||||
|
||||
// RIFF header
|
||||
putTag("RIFF");
|
||||
putU32(riffSize);
|
||||
putTag("WAVE");
|
||||
|
||||
// fmt chunk (16-byte body, WAVE_FORMAT_IEEE_FLOAT = 0x0003)
|
||||
putTag("fmt ");
|
||||
putU32(16u); // chunk body size
|
||||
putU16(0x0003u); // WAVE_FORMAT_IEEE_FLOAT
|
||||
putU16(static_cast<std::uint16_t>(nch));
|
||||
putU32(rate);
|
||||
putU32(rate * static_cast<std::uint32_t>(nch) * 4u); // avgBytesPerSec
|
||||
putU16(static_cast<std::uint16_t>(nch * 4)); // blockAlign
|
||||
putU16(32u); // bitsPerSample
|
||||
|
||||
// data chunk
|
||||
putTag("data");
|
||||
putU32(static_cast<std::uint32_t>(dataBytesCount));
|
||||
for (std::size_t i = 0; i < sampleCount && i < interleaved.size(); ++i)
|
||||
putF32(static_cast<float>(interleaved[i]));
|
||||
|
||||
return out;
|
||||
}
|
||||
// The 32f WAV build itself lives in the pure wav_codec module (Q-W3, audit §4e /
|
||||
// T4-10 — one owner of the RIFF layout, CTest-covered): buildFloat32Wav takes the
|
||||
// interleaved ReaSample (double) frames decoded below and yields the canonical
|
||||
// bank-format bytes (capture.cpp kRenderFormatWavFloat32; wav_codec.h FORMAT
|
||||
// ASSUMPTION — the double→float narrowing is the intentional bank contract).
|
||||
|
||||
// Decodes ALL samples from `src` into interleaved double-precision frames.
|
||||
// Returns empty on a zero-length or silent source (sampleRate < 1, channelCount == 0).
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#include "core/namespaces.h"
|
||||
// capture.cpp — REAPER-facing offline-render backend (OfflineRenderBackend).
|
||||
// capture.cpp — REAPER-facing offline-render backend (OfflineRenderBackend) plus
|
||||
// the shared backend helpers (makeUniqueTag / stampCaptureSample — Q-W3 riders).
|
||||
//
|
||||
// Compiled into the reaper_reasampler MODULE. Includes
|
||||
// reaper_plugin_functions.h WITHOUT REAPERAPI_IMPLEMENT — main.cpp is the one TU
|
||||
@@ -36,6 +36,7 @@
|
||||
|
||||
#include "shell/capture/capture.h"
|
||||
|
||||
#include <atomic>
|
||||
#include <cstdint>
|
||||
#include <ctime>
|
||||
#include <filesystem>
|
||||
@@ -44,6 +45,7 @@
|
||||
#include <vector>
|
||||
|
||||
#include "core/capture/capture_paths.h"
|
||||
#include "core/capture/wav_codec.h" // hashWavContent — the one WAV/RIFF owner
|
||||
#include "core/util/file_bytes.h"
|
||||
#include "core/capture/render_settings.h"
|
||||
|
||||
@@ -58,7 +60,7 @@
|
||||
#define REAPERAPI_WANT_TimeMap_GetTimeSigAtTime
|
||||
#include "reaper_plugin_functions.h"
|
||||
|
||||
namespace reasampler {
|
||||
namespace reasampler::capture {
|
||||
|
||||
namespace {
|
||||
|
||||
@@ -219,21 +221,84 @@ struct ScopedRenderSettings {
|
||||
ScopedRenderSettings& operator=(const ScopedRenderSettings&) = delete;
|
||||
};
|
||||
|
||||
// A monotonic, filesystem-safe timestamp tag so repeated captures in one session
|
||||
// do not collide on the file name. NOTE: the tag varies the file NAME, not the
|
||||
// audio bytes — bit-identical-repeat is about identical *content* for identical
|
||||
// requests; two deliberate captures naturally live in two files.
|
||||
std::string makeUniqueTag() {
|
||||
std::time_t now = std::time(nullptr);
|
||||
return std::to_string(static_cast<long long>(now));
|
||||
}
|
||||
|
||||
// Whole-file reads go through the shared core/util readFileBytes (Q-W1, T2-03):
|
||||
// empty on any I/O failure (the caller then leaves contentHash empty — the safe,
|
||||
// confirm-eliciting direction for an unreadable file).
|
||||
|
||||
} // namespace
|
||||
|
||||
// --- Shared backend helpers (Q-W3 riders — see capture.h) --------------------
|
||||
|
||||
std::string makeUniqueTag(const std::string& prefix) {
|
||||
// Timestamp + PER-SESSION MONOTONIC counter (T1-11 fix). The timestamp alone
|
||||
// had one-second resolution: two captures of the same baseName within the same
|
||||
// wall-clock second derived the same file stem, so the second render silently
|
||||
// overwrote the first file and minted two Samples with colliding ids —
|
||||
// reachable in practice via batch capture. The counter (shared across both
|
||||
// backends — this is the one definition both call) makes every tag of a
|
||||
// session distinct regardless of timing. NOTE: the tag varies the file NAME,
|
||||
// not the audio bytes — bit-identical-repeat is about identical *content* for
|
||||
// identical requests; two deliberate captures naturally live in two files.
|
||||
static std::atomic<unsigned long long> counter{0};
|
||||
const std::time_t now = std::time(nullptr);
|
||||
return prefix + std::to_string(static_cast<long long>(now)) + "-" +
|
||||
std::to_string(++counter);
|
||||
}
|
||||
|
||||
void stampCaptureSample(Sample& s, const CaptureRequest& req,
|
||||
ReaProject* rateProj, ReaProject* timeSigProj,
|
||||
const std::string& absolutePath) {
|
||||
// Track GUIDs + channel count: echoed from the request (the caller resolved
|
||||
// the selection; the backends stay source-agnostic).
|
||||
s.trackGuids = req.trackGuids;
|
||||
s.channelCount = req.channelCount;
|
||||
|
||||
// Resolved sample rate: the request's pinned rate, else PROJECT_SRATE read
|
||||
// from the caller's project handle. PROJECT_SRATE can read 0 on a project that
|
||||
// never explicitly pinned a rate — the value stays 0 (the Sample zero-value)
|
||||
// rather than a bogus literal (the honest "unknown" both backends shared).
|
||||
s.sampleRate = (req.sampleRate > 0)
|
||||
? req.sampleRate
|
||||
: static_cast<int>(GetSetProjectInfo(rateProj, "PROJECT_SRATE", 0.0, false));
|
||||
|
||||
s.captureTempo = Master_GetTempo(); // BPM at capture time (verified ~4651)
|
||||
|
||||
// Time signature at the capture's START time (L7 F1 stamp). TimeMap_GetTimeSigAtTime
|
||||
// (verified reaper_plugin_functions.h:7130 — void(ReaProject*, double time,
|
||||
// int* numOut, int* denomOut, double* tempoOut)) reads the meter effective at
|
||||
// that project time, so a sample captured under 3/4 keeps a 3/4 read-out even
|
||||
// if the project later switches to 4/4. `timeSigProj` is the CALLER's project
|
||||
// pin — offline passes nullptr (the active project); realtime pins the record's
|
||||
// own project (the T2-09 divergence, kept caller-visible as this argument).
|
||||
// tempoOut is ignored — captureTempo already carries the master tempo. Leaves
|
||||
// 0/0 (unstamped) if the API is somehow unavailable; the formatter renders a
|
||||
// blank musical read-out.
|
||||
{
|
||||
int tsNum = 0, tsDenom = 0;
|
||||
double tsTempo = 0.0;
|
||||
TimeMap_GetTimeSigAtTime(timeSigProj, req.startSeconds, &tsNum, &tsDenom, &tsTempo);
|
||||
s.captureTimeSigNum = tsNum;
|
||||
s.captureTimeSigDenom = tsDenom;
|
||||
}
|
||||
|
||||
// Content hash: WAV-aware FNV-1a over the finished file's fmt+data chunks so
|
||||
// hashReferencedElsewhere can identify copies in other banks and suppress the
|
||||
// last-reference confirm when another bank still holds the same file. Using
|
||||
// hashWavContent (not the raw hashBytes) skips render-varying metadata chunks
|
||||
// (bext origination timestamp, iXML, LIST/INFO, etc.) so two renders/records of
|
||||
// identical audio collapse to the same hash. Best-effort: an unreadable file
|
||||
// leaves contentHash empty — the safe, confirm-eliciting direction (bank_model
|
||||
// treats "" as non-participating in dedup).
|
||||
{
|
||||
const std::vector<std::uint8_t> fileBytes = util::readFileBytes(absolutePath);
|
||||
if (!fileBytes.empty()) {
|
||||
s.contentHash = hashWavContent(fileBytes);
|
||||
}
|
||||
}
|
||||
|
||||
s.createdTimestamp = static_cast<std::int64_t>(std::time(nullptr));
|
||||
}
|
||||
|
||||
CaptureResult OfflineRenderBackend::capture(const CaptureRequest& request) {
|
||||
CaptureResult result;
|
||||
|
||||
@@ -340,9 +405,9 @@ CaptureResult OfflineRenderBackend::capture(const CaptureRequest& request) {
|
||||
}();
|
||||
|
||||
// Compute the unique tag ONCE so the file stem and Sample.id carry the same
|
||||
// timestamp. Calling makeUniqueTag() twice could yield different values if a
|
||||
// second boundary crosses between the two calls (bug: id and filename diverge).
|
||||
const std::string uniqueTag = makeUniqueTag();
|
||||
// tag. Calling makeUniqueTag() twice would yield different values (the counter
|
||||
// advances per call — bug: id and filename diverge).
|
||||
const std::string uniqueTag = makeUniqueTag("");
|
||||
const BankPaths paths =
|
||||
deriveBankPaths(projectDir, request.baseName, uniqueTag);
|
||||
|
||||
@@ -477,50 +542,16 @@ CaptureResult OfflineRenderBackend::capture(const CaptureRequest& request) {
|
||||
// Seconds are the authoritative source for the render. Do NOT add DAW-
|
||||
// unverifiable PPQ resolution here — it requires a live REAPER to validate.
|
||||
s.wetDry = request.wetDry;
|
||||
// Track GUIDs for track-scoped captures (empty for master/items/razor). The
|
||||
// caller resolved the selection to canonical GUID strings; we record them so a
|
||||
// "re-capture from source" (M10) knows which tracks the sample came from.
|
||||
s.trackGuids = request.trackGuids;
|
||||
s.channelCount = request.channelCount;
|
||||
// Store the resolved sample rate only when it is known (> 0). If the project
|
||||
// never pinned a rate (PROJECT_SRATE read 0), we did not force RENDER_SRATE
|
||||
// either, so the render ran at REAPER's project default — an unknown value from
|
||||
// this code's perspective. Leave sampleRate at 0 (the Sample zero-value) rather
|
||||
// than store a bogus literal; M6/M7 can fill it in by probing the rendered file.
|
||||
s.sampleRate = effectiveSampleRate; // 0 when project rate was unknown
|
||||
s.lengthSeconds = request.endSeconds - request.startSeconds;
|
||||
s.captureTempo = Master_GetTempo(); // BPM at capture time (verified ~4651)
|
||||
// Time signature at the capture's START time (L7 F1 stamp). TimeMap_GetTimeSigAtTime
|
||||
// (verified reaper_plugin_functions.h:7130 — void(ReaProject*, double time,
|
||||
// int* numOut, int* denomOut, double* tempoOut)) reads the meter effective at that
|
||||
// project time, so a sample captured under 3/4 keeps a 3/4 read-out even if the
|
||||
// project later switches to 4/4. proj=nullptr => the active project (matches the
|
||||
// Master_GetTempo() call above, which is also active-project). The tempoOut is
|
||||
// ignored — captureTempo already carries the master tempo. Leaves 0/0 (unstamped)
|
||||
// if the API is somehow unavailable; the formatter renders a blank musical read-out.
|
||||
{
|
||||
int tsNum = 0, tsDenom = 0;
|
||||
double tsTempo = 0.0;
|
||||
TimeMap_GetTimeSigAtTime(nullptr, request.startSeconds, &tsNum, &tsDenom, &tsTempo);
|
||||
s.captureTimeSigNum = tsNum;
|
||||
s.captureTimeSigDenom = tsDenom;
|
||||
}
|
||||
s.tier = Tier::Scratch; // captures land in scratch by default
|
||||
// Content hash: WAV-aware FNV-1a over the rendered file's fmt+data chunks so
|
||||
// hashReferencedElsewhere can identify copies in other banks and suppress the
|
||||
// last-reference confirm when another bank still holds the same file. Using
|
||||
// hashWavContent (not the raw hashBytes) skips render-varying metadata chunks
|
||||
// (bext origination timestamp, iXML, LIST/INFO, etc.) so two renders of identical
|
||||
// audio collapse to the same hash. Best-effort: an unreadable file leaves
|
||||
// contentHash empty — the safe, confirm-eliciting direction (bank_model treats
|
||||
// "" as non-participating in dedup, which is the existing fallback semantics).
|
||||
{
|
||||
const std::vector<std::uint8_t> fileBytes = readFileBytes(expectedPath);
|
||||
if (!fileBytes.empty()) {
|
||||
s.contentHash = hashWavContent(fileBytes);
|
||||
}
|
||||
}
|
||||
s.createdTimestamp = static_cast<std::int64_t>(std::time(nullptr));
|
||||
s.tier = model::Tier::Scratch; // captures land in scratch by default
|
||||
// The SHARED finished-capture stamp (T2-09 dedupe): trackGuids + channelCount
|
||||
// (request echo), resolved sampleRate (request rate else PROJECT_SRATE(proj) —
|
||||
// 0 stays 0 when the project never pinned a rate; we did not force RENDER_SRATE
|
||||
// either, so the render ran at REAPER's default), captureTempo, the capture-
|
||||
// start time signature (timeSigProj = nullptr => the active project — matching
|
||||
// the Master_GetTempo read, which is also active-project), the WAV-aware
|
||||
// contentHash of the rendered file, and createdTimestamp.
|
||||
stampCaptureSample(s, request, proj, /*timeSigProj=*/nullptr, expectedPath);
|
||||
// Phase S seam fields (rootNote / loop) left empty (D-B). An offline render of a
|
||||
// master mix / track / time-selection is not a single played note, so no root
|
||||
// note is derivable here — we do NOT guess one. Loop points are set later by an
|
||||
@@ -537,4 +568,4 @@ CaptureResult OfflineRenderBackend::capture(const CaptureRequest& request) {
|
||||
// guard's dtor restores every RENDER_* setting here.
|
||||
}
|
||||
|
||||
} // namespace reasampler
|
||||
} // namespace reasampler::capture
|
||||
|
||||
+70
-41
@@ -1,19 +1,23 @@
|
||||
#pragma once
|
||||
#include "core/namespaces.h"
|
||||
// capture — the REAPER-facing capture shell (CLAUDE.md §load-bearing split).
|
||||
//
|
||||
// This header declares the capture *seam* the later milestones fill:
|
||||
// * CaptureRequest — everything a capture needs, source-mode-agnostic.
|
||||
// * ICaptureBackend — the SYNCHRONOUS interface OfflineRenderBackend implements
|
||||
// (headless, immediate, returns a finished Sample).
|
||||
// * OfflineRenderBackend — the deterministic default; drives the offline scopes.
|
||||
// * OfflineRenderBackend — the deterministic default; a plain CONCRETE class
|
||||
// (the former ICaptureBackend interface was deleted in
|
||||
// Q-W3, T4-26 — it had one deriver and zero polymorphic
|
||||
// call sites; every construction site instantiates the
|
||||
// concrete type).
|
||||
// * RealtimeRecordBackend — the ASYNC realtime seam (begin/tick/abort), driven
|
||||
// across timer ticks; deliberately NOT an ICaptureBackend
|
||||
// across timer ticks; a genuinely different lifecycle
|
||||
// (see the SEAM CHOICE note at its declaration).
|
||||
// * makeUniqueTag / stampCaptureSample — the shared file-tag mint and the shared
|
||||
// finished-capture metadata stamp both backends call
|
||||
// (Q-W3 riders T1-11 / T2-09).
|
||||
//
|
||||
// It includes bank_model (pure) to hand back a populated Sample, but NO REAPER
|
||||
// headers — the .cpp is the REAPER-facing translation unit. Keeping this header
|
||||
// REAPER-free lets callers (main.cpp, future actions.cpp) depend on the seam
|
||||
// REAPER-free lets callers (the capture orchestration TUs) depend on the seam
|
||||
// without dragging the SDK into every include site.
|
||||
|
||||
#include <memory>
|
||||
@@ -23,13 +27,17 @@
|
||||
#include "core/model/bank_model.h"
|
||||
#include "core/capture/render_settings.h" // TailMode (pure) — the three-state tail contract
|
||||
|
||||
// MediaTrack is forward-declared (like track_guid.h) so this header stays
|
||||
// REAPER-free while RealtimeRecordBackend::begin can take the resolved source
|
||||
// MediaTrack* to tap. The pointers are opaque here — never dereferenced in a
|
||||
// pure/header context; only the REAPER-facing capture_realtime.cpp touches them.
|
||||
// MediaTrack / ReaProject are forward-declared (like track_guid.h) so this header
|
||||
// stays REAPER-free while RealtimeRecordBackend::begin can take the resolved source
|
||||
// MediaTrack* to tap and stampCaptureSample can take the project handles its reads
|
||||
// pin. The pointers are opaque here — never dereferenced in a pure/header context;
|
||||
// only the REAPER-facing capture TUs touch them.
|
||||
class MediaTrack;
|
||||
class ReaProject;
|
||||
|
||||
namespace reasampler {
|
||||
namespace reasampler::capture {
|
||||
|
||||
using model::Sample;
|
||||
|
||||
// Audio bit-depth for the rendered wav. 32-bit float is the M3 default —
|
||||
// rationale lives in capture.cpp next to the sink-config bytes.
|
||||
@@ -106,27 +114,48 @@ struct CaptureResult {
|
||||
std::string message; // human-readable detail for the console log
|
||||
};
|
||||
|
||||
// The capture seam. One method: run a request, return a populated Sample (or a
|
||||
// failure code). Backends are non-destructive — they must restore any global
|
||||
// state they touch before returning (OfflineRenderBackend snapshots/restores the
|
||||
// RENDER_* project settings).
|
||||
class ICaptureBackend {
|
||||
public:
|
||||
virtual ~ICaptureBackend() = default;
|
||||
virtual CaptureResult capture(const CaptureRequest& request) = 0;
|
||||
};
|
||||
|
||||
// Deterministic offline-render backend. Drives the full offline source family —
|
||||
// master mix / time selection, selected tracks, selected items, razor area — all
|
||||
// wet-only (render_settings.h) with optional tail. The source selection + range
|
||||
// are resolved by the caller (the action layer) and handed in via the
|
||||
// CaptureRequest; the backend drives RENDER_* and never reads the DAW selection
|
||||
// itself. SourceMode::Realtime returns UnsupportedMode (that is the M8 backend).
|
||||
class OfflineRenderBackend : public ICaptureBackend {
|
||||
// Non-destructive: restores every RENDER_* setting it touches on every path.
|
||||
// A plain concrete class — the former ICaptureBackend interface was deleted
|
||||
// (Q-W3, T4-26): it had one deriver, zero polymorphic call sites, and the async
|
||||
// realtime backend deliberately never implemented it (see SEAM CHOICE below).
|
||||
class OfflineRenderBackend {
|
||||
public:
|
||||
CaptureResult capture(const CaptureRequest& request) override;
|
||||
CaptureResult capture(const CaptureRequest& request);
|
||||
};
|
||||
|
||||
// --- Shared backend helpers (Q-W3 riders) ------------------------------------
|
||||
|
||||
// Mints the filesystem-safe disambiguating tag for one capture's file stem +
|
||||
// Sample id: "<prefix><unix-epoch-seconds>-<n>" where <n> is a PER-SESSION
|
||||
// MONOTONIC counter (T1-11 fix). The wall-clock second alone had a collision
|
||||
// window: two captures of the same baseName within one second derived the same
|
||||
// stem, so the second render silently overwrote the first file (reachable via
|
||||
// batch capture driving short renders back-to-back). The counter makes every tag
|
||||
// of a session distinct regardless of timing. `prefix` is the backend's family
|
||||
// marker ("" offline, "rt-" realtime).
|
||||
std::string makeUniqueTag(const std::string& prefix);
|
||||
|
||||
// Stamps the SHARED finished-capture metadata onto `s` (T2-09 dedupe — this stamp
|
||||
// was copy-pasted per backend and had silently diverged): trackGuids +
|
||||
// channelCount (echoed from the request), the resolved sampleRate (request rate,
|
||||
// else PROJECT_SRATE read from `rateProj`; 0 stays 0 when unknown), captureTempo
|
||||
// (Master_GetTempo), the capture-start time signature (TimeMap_GetTimeSigAtTime
|
||||
// against `timeSigProj` — the offline path passes nullptr = active project, the
|
||||
// realtime path pins the record's own project; the divergence stays caller-visible
|
||||
// as this argument), the WAV-aware contentHash of the finished file at
|
||||
// `absolutePath` (left empty when unreadable — the safe, confirm-eliciting
|
||||
// direction), and createdTimestamp (now). The per-backend bits (id, paths, bounds,
|
||||
// tier, realtime's recorded-length override) stay with each caller.
|
||||
void stampCaptureSample(Sample& s, const CaptureRequest& req,
|
||||
ReaProject* rateProj, ReaProject* timeSigProj,
|
||||
const std::string& absolutePath);
|
||||
|
||||
// --- Realtime-record backend: the ASYNC seam ---------------------------------
|
||||
//
|
||||
// A realtime record is inherently asynchronous: CSurf_OnRecord starts the transport
|
||||
@@ -137,16 +166,17 @@ public:
|
||||
// from the same OnTimer that runs session.poll()) advances the in-flight record and
|
||||
// reports when it is done.
|
||||
//
|
||||
// SEAM CHOICE (surfaced): RealtimeRecordBackend deliberately does NOT implement the
|
||||
// synchronous ICaptureBackend — that interface returns a finished Sample from one
|
||||
// call, which no longer fits a record that spans ticks. The two backends have
|
||||
// genuinely different lifecycles (offline is headless + immediate; realtime is
|
||||
// transport-driven + async), so forcing a shared async interface would make offline
|
||||
// fake a lifecycle it does not have (its tick() would always be Done on the first
|
||||
// call — dead code / an LSP smell). Offline stays synchronous and unchanged; the
|
||||
// realtime backend owns this small bespoke async seam, driven by exactly one caller
|
||||
// (main.cpp's OnTimer). This is the split-sync/async fork, chosen over a unified
|
||||
// async interface for that reason.
|
||||
// SEAM CHOICE (surfaced): the two backends deliberately share NO interface. The
|
||||
// lifecycles are genuinely different (offline is headless + immediate — one
|
||||
// synchronous capture() call returns a finished Sample; realtime is
|
||||
// transport-driven + async — begin/tick/abort across timer ticks), so a shared
|
||||
// interface would make offline fake a lifecycle it does not have (its tick()
|
||||
// would always be Done on the first call — dead code / an LSP smell). Offline
|
||||
// stays synchronous; the realtime backend owns this small bespoke async seam,
|
||||
// driven by exactly one caller (the timer-driven realtime_lifecycle). This is the
|
||||
// split-sync/async fork, chosen over a unified async interface for that reason.
|
||||
// (The old synchronous ICaptureBackend interface over OfflineRenderBackend was
|
||||
// deleted in Q-W3 — T4-26: one deriver, zero polymorphic call sites.)
|
||||
|
||||
// One tick's verdict from the in-flight record.
|
||||
enum class RealtimeTickStatus {
|
||||
@@ -163,9 +193,8 @@ struct RealtimeTickResult {
|
||||
// The opaque in-flight capture state. Owns the snapshot of everything to restore
|
||||
// (temp track + its receive sends from the source tracks, other tracks' I_RECARM,
|
||||
// transport, edit cursor, time selection) and the record's own project handle.
|
||||
// Defined in
|
||||
// capture_realtime.cpp; the header stays REAPER-free (no MediaTrack*/ReaProject*
|
||||
// leaks here) by holding it behind a forward-declared type + unique_ptr.
|
||||
// Defined in capture_realtime_shell.cpp; the header stays REAPER-free (nothing is
|
||||
// dereferenced here) by holding it behind a forward-declared type + unique_ptr.
|
||||
//
|
||||
// restore()/teardown is idempotent and lives ON THIS OBJECT (not a function-scope
|
||||
// RAII guard) because the record spans ticks — no single stack frame outlives it.
|
||||
@@ -173,10 +202,10 @@ struct RealtimeTickResult {
|
||||
// funnels through the same single restore, safe to call once from whichever fires.
|
||||
class RealtimeCaptureState;
|
||||
|
||||
// Out-of-line deleter so callers (main.cpp) can own a unique_ptr to the opaque
|
||||
// RealtimeCaptureState WITHOUT its full (REAPER-typed) definition — the delete is
|
||||
// compiled in capture_realtime.cpp where the type is complete, keeping this header
|
||||
// REAPER-free (load-bearing split).
|
||||
// Out-of-line deleter so callers (realtime_lifecycle) can own a unique_ptr to the
|
||||
// opaque RealtimeCaptureState WITHOUT its full (REAPER-typed) definition — the
|
||||
// delete is compiled in capture_realtime_shell.cpp where the type is complete,
|
||||
// keeping this header REAPER-free (load-bearing split).
|
||||
struct RealtimeCaptureStateDeleter {
|
||||
void operator()(RealtimeCaptureState* p) const noexcept;
|
||||
};
|
||||
@@ -232,4 +261,4 @@ public:
|
||||
RealtimeTickResult abort(RealtimeCaptureState& state);
|
||||
};
|
||||
|
||||
} // namespace reasampler
|
||||
} // namespace reasampler::capture
|
||||
|
||||
@@ -0,0 +1,523 @@
|
||||
// capture_batch.cpp — the M11 batch-capture family + the M10 re-capture-from-source
|
||||
// action (Q-W3 hoist out of main.cpp; the code moved verbatim, the session threaded
|
||||
// as a parameter). See the header.
|
||||
//
|
||||
// Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.h
|
||||
// WITHOUT REAPERAPI_IMPLEMENT — main.cpp is the one TU that defines the API
|
||||
// pointers; here they are extern (CLAUDE.md §contract).
|
||||
|
||||
#include "shell/capture/capture_batch.h"
|
||||
|
||||
#include <cstddef>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#include "bank_panel.h" // bankPanelSelectedSampleIds / Refresh
|
||||
#include "core/capture/batch_capture.h" // planCaptureUnits / BatchOutcome
|
||||
#include "core/model/bank_book.h" // BankBook / Bank
|
||||
#include "core/model/provenance.h" // recipe parse/build, fingerprint
|
||||
#include "persist.h" // ReaSamplerSession
|
||||
#include "shell/capture/capture_orchestrator.h" // captureAndIndexOne / renderOffline
|
||||
#include "shell/capture/provenance_shell.h" // fxChainIdentity* / trackByGuid
|
||||
#include "shell/capture/scope_resolve.h" // ResolvedSource
|
||||
#include "shell/capture/track_guid.h" // guidString
|
||||
|
||||
#include "reaper_plugin.h" // UNDO_STATE_MISCCFG
|
||||
|
||||
#define REAPERAPI_MINIMAL
|
||||
#define REAPERAPI_WANT_CountSelectedMediaItems
|
||||
#define REAPERAPI_WANT_GetSelectedMediaItem
|
||||
#define REAPERAPI_WANT_GetMediaItem_Track
|
||||
#define REAPERAPI_WANT_GetMediaItemInfo_Value
|
||||
#define REAPERAPI_WANT_CountMediaItems
|
||||
#define REAPERAPI_WANT_GetMediaItem
|
||||
#define REAPERAPI_WANT_SetMediaItemSelected
|
||||
#define REAPERAPI_WANT_UpdateArrange
|
||||
#define REAPERAPI_WANT_CountTracks
|
||||
#define REAPERAPI_WANT_GetTrack
|
||||
#define REAPERAPI_WANT_GetSetMediaTrackInfo_String
|
||||
#define REAPERAPI_WANT_CountSelectedTracks
|
||||
#define REAPERAPI_WANT_GetSelectedTrack
|
||||
#define REAPERAPI_WANT_SetTrackSelected
|
||||
#define REAPERAPI_WANT_SetOnlyTrackSelected
|
||||
#define REAPERAPI_WANT_ShowConsoleMsg
|
||||
#define REAPERAPI_WANT_Undo_BeginBlock2
|
||||
#define REAPERAPI_WANT_Undo_EndBlock2
|
||||
#include "reaper_plugin_functions.h"
|
||||
|
||||
namespace reasampler::capture {
|
||||
|
||||
// --- M11: batch capture (per selected item / per razor area) ----------------
|
||||
//
|
||||
// One action fires N captures — one bank sample per selected item (item scope) or per
|
||||
// razor area (track scope, each area's own range). Each individual capture honors every
|
||||
// precision invariant via captureAndIndexOne (exact bounds, non-destructive FX/fader/pan
|
||||
// neutralize, relative paths, channel preservation) and M10 provenance stamping applies
|
||||
// per capture where its detection rule matches. The load-bearing principle holds: each
|
||||
// unit writes a file + a bank index entry ONLY; nothing lands in the arrange.
|
||||
//
|
||||
// Per-unit FILE NAMING: each unit's baseName carries its ordinal ("item-1",
|
||||
// "item-2", ...) so two units are never asked to write the same stem within one
|
||||
// batch, and the shared makeUniqueTag now appends a per-session monotonic counter
|
||||
// (T1-11 fix) so even same-second units across batches cannot collide.
|
||||
|
||||
namespace {
|
||||
|
||||
// RAII snapshot/restore of the project's media-item selection. Batch item capture must
|
||||
// transiently select exactly one item per render (RENDER_SETTINGS &32 renders whatever is
|
||||
// selected); the user's ORIGINAL selection must be restored on EVERY exit path — including
|
||||
// a mid-batch failure or early return — because selection restoration is part of the
|
||||
// non-destructive invariant. Snapshot on construct (the currently-selected item set),
|
||||
// restore on destruct (deselect everything, then re-select exactly the snapshot).
|
||||
class ItemSelectionGuard
|
||||
{
|
||||
public:
|
||||
ItemSelectionGuard()
|
||||
{
|
||||
const int n = CountSelectedMediaItems(nullptr);
|
||||
for (int i = 0; i < n; ++i)
|
||||
if (MediaItem* it = GetSelectedMediaItem(nullptr, i))
|
||||
selected_.push_back(it);
|
||||
}
|
||||
|
||||
~ItemSelectionGuard()
|
||||
{
|
||||
// Deselect every item in the project, then re-select the snapshot — restoring the
|
||||
// exact original set regardless of what the batch selected in between. Iterate ALL
|
||||
// items (not just the currently-selected) so any transient selection is cleared.
|
||||
const int total = CountMediaItems(nullptr);
|
||||
for (int i = 0; i < total; ++i)
|
||||
if (MediaItem* it = GetMediaItem(nullptr, i))
|
||||
SetMediaItemSelected(it, false);
|
||||
for (MediaItem* it : selected_)
|
||||
SetMediaItemSelected(it, true);
|
||||
UpdateArrange(); // reflect the restored selection in the arrange view
|
||||
}
|
||||
|
||||
ItemSelectionGuard(const ItemSelectionGuard&) = delete;
|
||||
ItemSelectionGuard& operator=(const ItemSelectionGuard&) = delete;
|
||||
|
||||
private:
|
||||
std::vector<MediaItem*> selected_;
|
||||
};
|
||||
|
||||
// Selects exactly `item` (deselect-all then select-one) so the offline render's
|
||||
// selected-items bit (&32) captures a single item. Used inside the batch loop under the
|
||||
// ItemSelectionGuard, which restores the user's original selection afterward.
|
||||
void selectOnlyItem(MediaItem* item)
|
||||
{
|
||||
const int total = CountMediaItems(nullptr);
|
||||
for (int i = 0; i < total; ++i)
|
||||
if (MediaItem* it = GetMediaItem(nullptr, i))
|
||||
SetMediaItemSelected(it, it == item);
|
||||
}
|
||||
|
||||
// Collects every track's razor AUDIO areas as (owning track, range) pairs, preserving
|
||||
// track order then area order — the batch analog of resolveRazorRange, which unions them.
|
||||
// Read-only (never clears the razor selection). Reuses the pure parseRazorEdits parser.
|
||||
std::vector<std::pair<MediaTrack*, RazorRange>> collectRazorAreas()
|
||||
{
|
||||
std::vector<std::pair<MediaTrack*, RazorRange>> areas;
|
||||
const int n = CountTracks(nullptr);
|
||||
for (int i = 0; i < n; ++i)
|
||||
{
|
||||
MediaTrack* tr = GetTrack(nullptr, i);
|
||||
if (!tr) continue;
|
||||
std::vector<char> buf(8192, '\0');
|
||||
if (!GetSetMediaTrackInfo_String(tr, "P_RAZOREDITS", buf.data(), false))
|
||||
continue;
|
||||
for (const RazorRange& r : parseRazorEdits(std::string(buf.data())))
|
||||
areas.push_back({tr, r});
|
||||
}
|
||||
return areas;
|
||||
}
|
||||
|
||||
// RAII snapshot/restore of the project's TRACK selection. Batch razor capture must
|
||||
// transiently select exactly the area's owning track per render (track scope's &128 bit
|
||||
// renders whatever TRACKS are selected); the user's original track selection is restored
|
||||
// on EVERY exit path (part of the non-destructive invariant). Mirror of ItemSelectionGuard.
|
||||
class TrackSelectionGuard
|
||||
{
|
||||
public:
|
||||
TrackSelectionGuard()
|
||||
{
|
||||
const int n = CountSelectedTracks(nullptr);
|
||||
for (int i = 0; i < n; ++i)
|
||||
if (MediaTrack* tr = GetSelectedTrack(nullptr, i))
|
||||
selected_.push_back(tr);
|
||||
}
|
||||
|
||||
~TrackSelectionGuard()
|
||||
{
|
||||
// Deselect every track, then re-select the snapshot — the exact original set.
|
||||
const int total = CountTracks(nullptr);
|
||||
for (int i = 0; i < total; ++i)
|
||||
if (MediaTrack* tr = GetTrack(nullptr, i))
|
||||
SetTrackSelected(tr, false);
|
||||
for (MediaTrack* tr : selected_)
|
||||
SetTrackSelected(tr, true);
|
||||
}
|
||||
|
||||
TrackSelectionGuard(const TrackSelectionGuard&) = delete;
|
||||
TrackSelectionGuard& operator=(const TrackSelectionGuard&) = delete;
|
||||
|
||||
private:
|
||||
std::vector<MediaTrack*> selected_;
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
// Batch item capture: one bank sample per SELECTED item, item scope. Snapshots the
|
||||
// selection (RAII restore on every path), then for each selected item transiently selects
|
||||
// only it, renders its exact [pos, pos+len] range under item-scope FX neutralize, adds the
|
||||
// Sample, and records a per-unit verdict. Persists ONCE at the end (one ext-state write for
|
||||
// the whole batch). Reports a mixed-result summary (explicit-action response — allowed).
|
||||
void RunBatchCaptureItems(ReaSamplerSession& session)
|
||||
{
|
||||
// Read the selected items up front (pointers stay valid — batch mutates only selection
|
||||
// flags, never adds/removes items). Also capture each item's exact bounds and owning
|
||||
// track NOW, while the full selection is live, before any transient re-selection.
|
||||
struct ItemUnit { MediaItem* item; MediaTrack* track; double start; double end; };
|
||||
std::vector<ItemUnit> itemUnits;
|
||||
{
|
||||
const int n = CountSelectedMediaItems(nullptr);
|
||||
for (int i = 0; i < n; ++i)
|
||||
{
|
||||
MediaItem* it = GetSelectedMediaItem(nullptr, i);
|
||||
if (!it) continue;
|
||||
MediaTrack* tr = GetMediaItem_Track(it);
|
||||
if (!tr) continue;
|
||||
const double pos = GetMediaItemInfo_Value(it, "D_POSITION");
|
||||
const double len = GetMediaItemInfo_Value(it, "D_LENGTH");
|
||||
itemUnits.push_back({it, tr, pos, pos + len});
|
||||
}
|
||||
}
|
||||
if (itemUnits.empty())
|
||||
{
|
||||
ShowConsoleMsg("ReaSampler batch capture: select at least one media item.\n");
|
||||
return;
|
||||
}
|
||||
|
||||
// Plan the exact source ranges -> validated, ordinal-assigned units (pure). Empty/
|
||||
// inverted item ranges (a zero-length item) are dropped here so no stray render runs.
|
||||
std::vector<BatchRange> ranges;
|
||||
ranges.reserve(itemUnits.size());
|
||||
for (const ItemUnit& u : itemUnits)
|
||||
ranges.push_back({u.start, u.end});
|
||||
const std::vector<CaptureUnit> plan = planCaptureUnits(ranges);
|
||||
|
||||
BatchOutcome outcome;
|
||||
bool anyAdded = false;
|
||||
{
|
||||
// Restore the user's ORIGINAL item selection on every exit path (incl. early
|
||||
// return / mid-batch failure) — non-destructive invariant.
|
||||
ItemSelectionGuard selGuard;
|
||||
|
||||
// The plan and itemUnits are parallel over the KEPT units. Walk itemUnits, but only
|
||||
// for those whose range survived planning (same drop rule), matching by ordinal.
|
||||
std::size_t planIdx = 0;
|
||||
for (const ItemUnit& u : itemUnits)
|
||||
{
|
||||
if (!(u.end > u.start)) continue; // dropped by planCaptureUnits — skip in lockstep
|
||||
const CaptureUnit& unit = plan[planIdx++];
|
||||
|
||||
// Transiently select ONLY this item so the item-scope render captures exactly it.
|
||||
selectOnlyItem(u.item);
|
||||
|
||||
ResolvedSource src;
|
||||
src.startSeconds = unit.startSeconds;
|
||||
src.endSeconds = unit.endSeconds;
|
||||
src.sourceTracks.push_back(u.track);
|
||||
if (std::string g = guidString(u.track); !g.empty())
|
||||
src.trackGuids.push_back(std::move(g));
|
||||
|
||||
const std::string baseName = "item-" + std::to_string(unit.ordinal);
|
||||
CaptureResult res = captureAndIndexOne(
|
||||
session, CaptureScope::Item, src, baseName,
|
||||
unit.startSeconds, unit.endSeconds);
|
||||
|
||||
const bool ok = (res.status == CaptureStatus::Ok);
|
||||
outcome.record(unit.ordinal, ok, ok ? std::string{} : res.message);
|
||||
if (ok) anyAdded = true;
|
||||
}
|
||||
} // selGuard restores the original selection here, on every path
|
||||
|
||||
// Persist ONCE for the whole batch (one ext-state write) — only if something landed.
|
||||
// S9: one bump for the whole batch (coalesced) — the counter is monotonic, not per-sample,
|
||||
// so a single increment past the last-seen value is enough to trigger one instance reload.
|
||||
if (anyAdded) {
|
||||
session.bumpBankGeneration();
|
||||
session.saveToActiveProject();
|
||||
}
|
||||
|
||||
ShowConsoleMsg((outcome.summaryLine("item") + "\n").c_str());
|
||||
}
|
||||
|
||||
// Batch razor capture: one bank sample per razor AREA, track scope over that area's own
|
||||
// range (the area's owning track is the source track). Track scope renders the selected
|
||||
// TRACKS via master (&128), so each unit transiently selects ONLY its owning track
|
||||
// (SetOnlyTrackSelected) under the TrackSelectionGuard, which restores the user's original
|
||||
// track selection on every path. The razor selection itself is read-only and left intact.
|
||||
// Persists ONCE at the end. Reports a mixed-result summary.
|
||||
void RunBatchCaptureRazor(ReaSamplerSession& session)
|
||||
{
|
||||
const std::vector<std::pair<MediaTrack*, RazorRange>> areas = collectRazorAreas();
|
||||
if (areas.empty())
|
||||
{
|
||||
ShowConsoleMsg("ReaSampler batch capture: make at least one razor area first.\n");
|
||||
return;
|
||||
}
|
||||
|
||||
std::vector<BatchRange> ranges;
|
||||
ranges.reserve(areas.size());
|
||||
for (const auto& a : areas)
|
||||
ranges.push_back({a.second.startSeconds, a.second.endSeconds});
|
||||
const std::vector<CaptureUnit> plan = planCaptureUnits(ranges);
|
||||
|
||||
BatchOutcome outcome;
|
||||
bool anyAdded = false;
|
||||
{
|
||||
// Restore the user's ORIGINAL track selection on every exit path.
|
||||
TrackSelectionGuard selGuard;
|
||||
|
||||
std::size_t planIdx = 0;
|
||||
for (const auto& a : areas)
|
||||
{
|
||||
if (!(a.second.endSeconds > a.second.startSeconds)) continue; // dropped — lockstep
|
||||
const CaptureUnit& unit = plan[planIdx++];
|
||||
MediaTrack* tr = a.first;
|
||||
|
||||
// Transiently select ONLY this track so the track-scope render (&128) captures
|
||||
// exactly it via master (over the custom time bounds we set per unit).
|
||||
SetOnlyTrackSelected(tr);
|
||||
|
||||
ResolvedSource src;
|
||||
src.startSeconds = unit.startSeconds;
|
||||
src.endSeconds = unit.endSeconds;
|
||||
src.sourceTracks.push_back(tr);
|
||||
if (std::string g = guidString(tr); !g.empty())
|
||||
src.trackGuids.push_back(std::move(g));
|
||||
|
||||
const std::string baseName = "razor-" + std::to_string(unit.ordinal);
|
||||
CaptureResult res = captureAndIndexOne(
|
||||
session, CaptureScope::Track, src, baseName,
|
||||
unit.startSeconds, unit.endSeconds);
|
||||
|
||||
const bool ok = (res.status == CaptureStatus::Ok);
|
||||
outcome.record(unit.ordinal, ok, ok ? std::string{} : res.message);
|
||||
if (ok) anyAdded = true;
|
||||
}
|
||||
} // selGuard restores the original track selection here, on every path
|
||||
|
||||
// S9: one coalesced bump for the whole razor batch (see the item-batch note above).
|
||||
if (anyAdded) {
|
||||
session.bumpBankGeneration();
|
||||
session.saveToActiveProject();
|
||||
}
|
||||
|
||||
ShowConsoleMsg((outcome.summaryLine("razor area") + "\n").c_str());
|
||||
}
|
||||
|
||||
// --- M10: re-capture from source --------------------------------------------
|
||||
//
|
||||
// Regenerates a PROVENANCED bank sample's file from its recorded source's CURRENT
|
||||
// state, then updates the bank Sample IN PLACE. BANK-ONLY — it renders a file and
|
||||
// refreshes the index entry; it NEVER calls InsertMedia / touches the timeline (the
|
||||
// load-bearing capture-never-places line, structurally visible: this function has no
|
||||
// insert path at all). Non-destructive to the source (FxBypassGuard snapshot/restore
|
||||
// via renderOffline). Fork P2=a: refresh the bank entry only; the user re-places
|
||||
// manually if they want the new version on the timeline.
|
||||
//
|
||||
// Failure modes are handled explicitly and reported to the user (a direct response
|
||||
// to an explicit action is allowed by the console policy):
|
||||
// * the selected sample has no provenance (not a resample) -> reported, no-op.
|
||||
// * the recorded fingerprint is unparseable (legacy/corrupt) -> reported, no-op.
|
||||
// * the recorded source track(s) no longer exist -> reported, no-op.
|
||||
// * the render itself fails to satisfy the recorded request -> reported, no-op.
|
||||
// On success, if the source FX chain drifted since capture (recorded vs current
|
||||
// identity differ) the user is told — the re-capture still reflects the source AS IT
|
||||
// IS NOW (P1=a: the fingerprint detects drift, it does not freeze the source).
|
||||
void RunRecaptureFromSource(ReaSamplerSession& session)
|
||||
{
|
||||
const std::vector<std::string> selected = bankPanelSelectedSampleIds();
|
||||
if (selected.empty())
|
||||
{
|
||||
ShowConsoleMsg("ReaSampler re-capture: select a sample in the bank panel first.\n");
|
||||
return;
|
||||
}
|
||||
if (selected.size() > 1)
|
||||
{
|
||||
ShowConsoleMsg("ReaSampler re-capture: select a single sample to re-capture.\n");
|
||||
return;
|
||||
}
|
||||
const std::string sampleId = selected.front();
|
||||
|
||||
// Resolve the sample from the bank it lives in (the focused region's displayed bank).
|
||||
const std::string srcBankId = bankPanelSelectedSourceBankId();
|
||||
const Bank* bank = session.book().bank(srcBankId);
|
||||
const model::Sample* orig = bank ? bank->index.query(sampleId) : nullptr;
|
||||
if (!orig)
|
||||
{
|
||||
ShowConsoleMsg("ReaSampler re-capture: the selected sample is no longer in the bank.\n");
|
||||
return;
|
||||
}
|
||||
if (!orig->provenance)
|
||||
{
|
||||
ShowConsoleMsg("ReaSampler re-capture: this sample has no provenance "
|
||||
"(it was not resampled from a bank sample).\n");
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse the recorded capture recipe from the fingerprint. A legacy / corrupt
|
||||
// string fails gracefully — never a partial re-capture.
|
||||
const std::string recordedParentId = orig->provenance->parentSampleId;
|
||||
const std::string recordedFingerprint = orig->provenance->fxChainSnapshot;
|
||||
const std::optional<model::CaptureRecipe> recipe =
|
||||
model::parseFingerprint(recordedFingerprint);
|
||||
if (!recipe)
|
||||
{
|
||||
ShowConsoleMsg("ReaSampler re-capture: this sample's provenance is unreadable "
|
||||
"(recorded by an older/incompatible build); cannot re-capture.\n");
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve the recorded source track GUID(s) to live tracks. Any missing track is a
|
||||
// hard failure — we will not silently re-capture a different source.
|
||||
std::vector<MediaTrack*> sourceTracks;
|
||||
for (const std::string& g : recipe->trackGuids)
|
||||
{
|
||||
MediaTrack* tr = trackByGuid(g);
|
||||
if (!tr)
|
||||
{
|
||||
ShowConsoleMsg("ReaSampler re-capture: a recorded source track no longer "
|
||||
"exists in this project; cannot re-capture from source.\n");
|
||||
return;
|
||||
}
|
||||
sourceTracks.push_back(tr);
|
||||
}
|
||||
if (sourceTracks.empty())
|
||||
{
|
||||
// The recipe recorded no source tracks (e.g. an item-scope capture whose source
|
||||
// tracks were not track-scoped). Without a resolvable source we cannot re-run.
|
||||
ShowConsoleMsg("ReaSampler re-capture: no resolvable recorded source for this "
|
||||
"sample; cannot re-capture from source.\n");
|
||||
return;
|
||||
}
|
||||
|
||||
const CaptureScope scope =
|
||||
recipe->scope == model::ProvenanceScope::Item ? CaptureScope::Item
|
||||
: CaptureScope::Track;
|
||||
|
||||
// Rebuild the capture request verbatim from the recorded recipe — the SAME request,
|
||||
// re-run against the source's CURRENT state (P1=a). Exact bounds, tail, rate,
|
||||
// channels, bit depth all match the original so an unchanged source produces a
|
||||
// byte-identical file (bit-identical-repeats invariant, consumed as a feature).
|
||||
CaptureRequest req;
|
||||
req.sourceMode = static_cast<SourceMode>(recipe->sourceMode);
|
||||
req.startSeconds = recipe->startSeconds;
|
||||
req.endSeconds = recipe->endSeconds;
|
||||
req.wetDry = 1.0;
|
||||
req.tailMode = static_cast<TailMode>(recipe->tailMode);
|
||||
req.tailMs = recipe->tailMs;
|
||||
req.sampleRate = recipe->sampleRate;
|
||||
req.channelCount = recipe->channelCount;
|
||||
req.bitDepth = WavBitDepth::Float32;
|
||||
req.baseName = orig->displayName.empty() ? "recapture" : orig->displayName;
|
||||
req.trackGuids = recipe->trackGuids;
|
||||
|
||||
// Read the CURRENT source FX-chain identity BEFORE the render bypasses it, to
|
||||
// compare against the recorded identity for drift reporting. Mirror the same
|
||||
// scope split as buildCaptureProvenance: item scope reads take FX via TakeFX_*;
|
||||
// track scope reads the track FX chain via TrackFX_*.
|
||||
std::string currentIdentity;
|
||||
if (scope == CaptureScope::Item) {
|
||||
const int n = CountSelectedMediaItems(nullptr);
|
||||
std::vector<MediaItem*> items;
|
||||
items.reserve(static_cast<std::size_t>(n < 0 ? 0 : n));
|
||||
for (int i = 0; i < n; ++i) {
|
||||
MediaItem* it = GetSelectedMediaItem(nullptr, i);
|
||||
if (it) items.push_back(it);
|
||||
}
|
||||
currentIdentity = fxChainIdentityForItems(items);
|
||||
} else {
|
||||
std::vector<std::string> perTrackNow;
|
||||
perTrackNow.reserve(sourceTracks.size());
|
||||
for (MediaTrack* tr : sourceTracks)
|
||||
perTrackNow.push_back(fxChainIdentityForTrack(tr));
|
||||
currentIdentity = model::combineChainIdentities(perTrackNow);
|
||||
}
|
||||
const bool drifted = (currentIdentity != recipe->fxChainIdentity);
|
||||
|
||||
// Render (bank-only; renderOffline never touches the timeline).
|
||||
CaptureResult res = renderOffline(scope, sourceTracks, req);
|
||||
if (res.status != CaptureStatus::Ok)
|
||||
{
|
||||
ShowConsoleMsg(("ReaSampler re-capture failed: " + res.message + "\n").c_str());
|
||||
return;
|
||||
}
|
||||
|
||||
// Update the Sample IN PLACE: keep its identity (id) and its provenance thread
|
||||
// (same parent + a REFRESHED fingerprint reflecting the source as re-captured), but
|
||||
// adopt the regenerated file's path / hash / length / rate / timestamp. The
|
||||
// fingerprint is rebuilt from the recipe with the CURRENT FX identity so a
|
||||
// subsequent re-capture measures drift from this point, not the original.
|
||||
model::CaptureRecipe refreshed = *recipe;
|
||||
refreshed.fxChainIdentity = currentIdentity;
|
||||
|
||||
model::Sample updated = *orig; // copy: preserves id, displayName, tier, key
|
||||
updated.relativePath = res.sample.relativePath;
|
||||
updated.contentHash = res.sample.contentHash;
|
||||
updated.sourceMode = res.sample.sourceMode;
|
||||
updated.sourceRange = res.sample.sourceRange;
|
||||
updated.channelCount = res.sample.channelCount;
|
||||
updated.sampleRate = res.sample.sampleRate;
|
||||
updated.lengthSeconds = res.sample.lengthSeconds;
|
||||
updated.captureTempo = res.sample.captureTempo;
|
||||
updated.captureTimeSigNum = res.sample.captureTimeSigNum; // L7 F1: refresh meter stamp
|
||||
updated.captureTimeSigDenom = res.sample.captureTimeSigDenom; // to the re-capture's meter
|
||||
updated.trackGuids = res.sample.trackGuids;
|
||||
updated.createdTimestamp = res.sample.createdTimestamp;
|
||||
// NOTE: levels, clipped, and lengthBeats are carried from the original (via the
|
||||
// *orig copy above) because the offline backend does not populate them today
|
||||
// (res.sample leaves them at defaults). If a later milestone populates these
|
||||
// fields at capture time, refresh them here from res.sample instead.
|
||||
model::Provenance prov;
|
||||
prov.parentSampleId = recordedParentId;
|
||||
prov.fxChainSnapshot = model::buildFingerprint(refreshed);
|
||||
updated.provenance = prov;
|
||||
|
||||
// Single batched undo point around the in-place bank mutation (mirrors the bank
|
||||
// action family's R-B pattern). The mutation is index-only ext-state; the render
|
||||
// wrote a new file but placed nothing on the timeline.
|
||||
Undo_BeginBlock2(nullptr);
|
||||
const bool changed = session.book().updateSampleInPlace(sampleId, updated);
|
||||
if (changed)
|
||||
{
|
||||
// Record the regenerated file in the owned manifest (a new file the tool wrote);
|
||||
// the superseded old file becomes an orphan reclaimed by Phase R prune.
|
||||
session.owned().add(updated.relativePath);
|
||||
// S9: re-capture-in-place regenerates the SAME id's audio — the exact case the
|
||||
// hands-free refresh exists for (an instance referencing this id keeps playing the
|
||||
// OLD audio until it reloads). Bump inside the undo block so undo rolls back the
|
||||
// generation with the rest of the blob.
|
||||
session.bumpBankGeneration();
|
||||
const bool persisted = session.saveToActiveProject(); // book + manifest + generation + MarkProjectDirty
|
||||
Undo_EndBlock2(nullptr, persisted ? "ReaSampler: re-capture from source" : "",
|
||||
persisted ? UNDO_STATE_MISCCFG : 0);
|
||||
}
|
||||
else
|
||||
{
|
||||
Undo_EndBlock2(nullptr, "", 0); // nothing mutated -> discard the empty point
|
||||
}
|
||||
|
||||
bankPanelRefresh(); // reflect the regenerated file in the docked grid
|
||||
|
||||
if (drifted)
|
||||
ShowConsoleMsg("ReaSampler re-capture: the source FX chain changed since the "
|
||||
"original capture -- the sample was regenerated from the source's "
|
||||
"current state.\n");
|
||||
}
|
||||
|
||||
} // namespace reasampler::capture
|
||||
@@ -0,0 +1,32 @@
|
||||
#pragma once
|
||||
// capture_batch — the batch-capture family + re-capture-from-source (Q-W3 hoist
|
||||
// out of main.cpp; the fourth hoist, T4-02 — recapture is planner-driven like
|
||||
// batch and shares the RAII selection-guard machinery, so it belongs here, not
|
||||
// with the single-shot path). Owns:
|
||||
// * RunBatchCaptureItems — one bank sample per SELECTED item (item scope), the
|
||||
// user's item selection snapshot/restored on every path (ItemSelectionGuard);
|
||||
// * RunBatchCaptureRazor — one bank sample per razor AREA (track scope over the
|
||||
// area's own range), the user's track selection snapshot/restored on every
|
||||
// path (TrackSelectionGuard);
|
||||
// * RunRecaptureFromSource — regenerate a PROVENANCED bank sample from its
|
||||
// recorded source's CURRENT state, updating the Sample in place. BANK-ONLY.
|
||||
//
|
||||
// Every unit honors every precision invariant via capture_orchestrator's
|
||||
// captureAndIndexOne / renderOffline (exact bounds, non-destructive neutralize,
|
||||
// relative paths); nothing here ever touches the arrange/timeline (load-bearing
|
||||
// principle). Persist is batched: ONE ext-state write per action.
|
||||
//
|
||||
// REAPER-facing: the .cpp includes reaper_plugin_functions.h WITHOUT
|
||||
// REAPERAPI_IMPLEMENT (main.cpp owns the API pointers — CLAUDE.md §contract).
|
||||
|
||||
namespace reasampler {
|
||||
class ReaSamplerSession;
|
||||
}
|
||||
|
||||
namespace reasampler::capture {
|
||||
|
||||
void RunBatchCaptureItems(ReaSamplerSession& session);
|
||||
void RunBatchCaptureRazor(ReaSamplerSession& session);
|
||||
void RunRecaptureFromSource(ReaSamplerSession& session);
|
||||
|
||||
} // namespace reasampler::capture
|
||||
@@ -0,0 +1,487 @@
|
||||
// capture_orchestrator.cpp — the single-capture orchestration + realtime/insert
|
||||
// action bodies (Q-W3 hoist out of main.cpp; the code moved verbatim, the session
|
||||
// threaded as a parameter). See the header. FxBypassGuard lives here as a STACK
|
||||
// RAII object (precision-invariant-critical — it must restore on every exit path
|
||||
// of exactly one render call).
|
||||
//
|
||||
// Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.h
|
||||
// WITHOUT REAPERAPI_IMPLEMENT — main.cpp is the one TU that defines the API
|
||||
// pointers; here they are extern (CLAUDE.md §contract).
|
||||
|
||||
#include "shell/capture/capture_orchestrator.h"
|
||||
|
||||
#include <optional>
|
||||
#include <vector>
|
||||
|
||||
#include "bank_panel.h" // bankPanelTailSetting / bankPanelRefresh
|
||||
#include "core/capture/tail_control.h" // TailSetting
|
||||
#include "core/model/provenance.h" // model::Provenance
|
||||
#include "ingest.h" // ingestAssignActiveInstance
|
||||
#include "persist.h" // ReaSamplerSession
|
||||
#include "shell/capture/insert.h" // runInsert / InsertRequest
|
||||
#include "shell/capture/realtime_lifecycle.h" // the in-flight realtime state
|
||||
|
||||
#include "reaper_plugin.h" // UNDO_STATE_MISCCFG
|
||||
|
||||
#define REAPERAPI_MINIMAL
|
||||
#define REAPERAPI_WANT_EnumProjects
|
||||
#define REAPERAPI_WANT_GetParentTrack
|
||||
#define REAPERAPI_WANT_GetMasterTrack
|
||||
#define REAPERAPI_WANT_GetMediaTrackInfo_Value
|
||||
#define REAPERAPI_WANT_SetMediaTrackInfo_Value
|
||||
#define REAPERAPI_WANT_ShowConsoleMsg
|
||||
#define REAPERAPI_WANT_Undo_BeginBlock2
|
||||
#define REAPERAPI_WANT_Undo_EndBlock2
|
||||
#include "reaper_plugin_functions.h"
|
||||
|
||||
namespace reasampler::capture {
|
||||
|
||||
namespace {
|
||||
|
||||
// --- FX-bypass + full parent-chain neutralize around render (RAII, non-destr.) --
|
||||
// For every track a scope must NOT hear the FX of, this ALSO neutralizes that
|
||||
// track's fader gain AND its full pan chain (pan/width/law/mode) for the render —
|
||||
// because a Track/Item capture renders via master and would otherwise sum through
|
||||
// the parent/folder/master FADERS and PAN/WIDTH/LAW, printing their gain and pan
|
||||
// coloring into the file (Daniel: the capture is likely re-routed through that
|
||||
// same chain later, so parent/master level and pan must not be baked in). The
|
||||
// neutralize set is IDENTICAL to the FX-bypass set:
|
||||
// Item -> own track + all ancestors + master (take vol/pan kept: item content).
|
||||
// Track -> all ancestors + master (selected track's OWN vol/pan kept).
|
||||
// (Master is a bypass TARGET for both scopes — never a scope of its own.)
|
||||
//
|
||||
// Per track in that set we snapshot & set the full parent-chain-independence set,
|
||||
// so a Track/Item capture is uncolored by the parent/folder/master it renders
|
||||
// through — no FX, no fader, and no pan/width/law/mode coloring:
|
||||
// I_FXEN -> 0 (FX bypassed; SDK ~2194)
|
||||
// D_VOL -> 1.0 (unity trim volume; SDK ~2226 "1=+0dB")
|
||||
// D_PAN -> 0.0 (center; SDK ~2227 "trim pan of track, -1..1")
|
||||
// D_WIDTH -> 1.0 (full/neutral stereo width; SDK ~2228 "width, -1..1",
|
||||
// 1.0 = full width = no narrowing/collapse)
|
||||
// D_PANLAW -> 1.0 (no coloring; SDK ~2232 "1=+0dB" — pan-law applies no gain)
|
||||
// I_PANMODE -> 5 (stereo pan; SDK ~2231 "0=classic,3=balance,5=stereo,6=dual")
|
||||
// All are restored to their ORIGINAL values on EVERY exit path (RAII).
|
||||
//
|
||||
// Why also force I_PANMODE (pan mode). D_PAN's effect is mode-dependent. In modes
|
||||
// 0/3/5, D_PAN=0 + D_WIDTH=1 is a provable pass-through. But in mode 6 (dual pan)
|
||||
// D_PAN/D_WIDTH are ignored — routing is governed instead by D_DUALPANL/D_DUALPANR
|
||||
// (SDK ~2229-2230, live only when I_PANMODE==6), whose neutral pass-through the
|
||||
// header does not state as such. Rather than snapshot two more mode-conditional
|
||||
// params and infer their neutral values, we force I_PANMODE=5 (stereo pan) for the
|
||||
// render, where D_PAN=0 + D_WIDTH=1 is unambiguously uncolored, then restore the
|
||||
// original mode. This fully neutralizes pan for every original mode with no
|
||||
// residual — the "handle it fully" the brief requires. (See Snap dual-pan note.)
|
||||
//
|
||||
// Structurally non-destructive: no takes, no items, no project restructuring —
|
||||
// only transient FX-enable + trim-volume toggles, always restored.
|
||||
class FxBypassGuard
|
||||
{
|
||||
public:
|
||||
// scope drives fxBypassPlanFor; sourceTracks are the captured tracks whose
|
||||
// ancestor chains (walked via GetParentTrack) + the master are bypassed per the
|
||||
// plan. proj is the active project (for GetMasterTrack).
|
||||
FxBypassGuard(CaptureScope scope,
|
||||
const std::vector<MediaTrack*>& sourceTracks,
|
||||
ReaProject* proj)
|
||||
{
|
||||
const FxBypassPlan plan = fxBypassPlanFor(scope);
|
||||
|
||||
for (MediaTrack* tr : sourceTracks)
|
||||
{
|
||||
if (!tr) continue;
|
||||
if (plan.bypassSelfFx) bypass(tr);
|
||||
if (plan.bypassAncestorFx)
|
||||
{
|
||||
// Walk parents to the top: GetParentTrack returns the immediate
|
||||
// parent (folder) track, nullptr at the outermost level (SDK
|
||||
// header ~2407). The master is NOT returned here — handled below.
|
||||
for (MediaTrack* p = GetParentTrack(tr); p; p = GetParentTrack(p))
|
||||
bypass(p);
|
||||
}
|
||||
}
|
||||
if (plan.bypassMaster)
|
||||
{
|
||||
// GetMasterTrack(proj) -> the master track (SDK header ~1925). bypass()
|
||||
// neutralizes its FX (I_FXEN), gain (D_VOL) AND pan/width/law/mode on it
|
||||
// just like any other in-scope track; only the master's summing/routing
|
||||
// topology (the mix bus itself) remains — that is not a per-track param.
|
||||
if (MediaTrack* master = GetMasterTrack(proj)) bypass(master);
|
||||
}
|
||||
}
|
||||
|
||||
~FxBypassGuard()
|
||||
{
|
||||
// Restore in reverse for symmetry (order is not load-bearing — each track
|
||||
// appears once, snapshots are independent). EVERY snapshotted param is
|
||||
// restored to its ORIGINAL value on this (every) exit path. Restore
|
||||
// I_PANMODE before the pan values so any mode-conditional params (e.g. dual
|
||||
// pan) settle under the original mode.
|
||||
for (auto it = snapshots_.rbegin(); it != snapshots_.rend(); ++it)
|
||||
{
|
||||
SetMediaTrackInfo_Value(it->track, "I_FXEN", it->fxen);
|
||||
SetMediaTrackInfo_Value(it->track, "D_VOL", it->vol);
|
||||
SetMediaTrackInfo_Value(it->track, "I_PANMODE", it->panmode);
|
||||
SetMediaTrackInfo_Value(it->track, "D_PAN", it->pan);
|
||||
SetMediaTrackInfo_Value(it->track, "D_WIDTH", it->width);
|
||||
SetMediaTrackInfo_Value(it->track, "D_PANLAW", it->panlaw);
|
||||
}
|
||||
}
|
||||
|
||||
FxBypassGuard(const FxBypassGuard&) = delete;
|
||||
FxBypassGuard& operator=(const FxBypassGuard&) = delete;
|
||||
|
||||
private:
|
||||
// One snapshot per bypassed track: all params we neutralize, at their originals.
|
||||
// panmode captures I_PANMODE so we can force stereo-pan for the render and put
|
||||
// the original mode back — which also makes D_DUALPANL/D_DUALPANR (live only when
|
||||
// I_PANMODE==6, SDK ~2229-2230) irrelevant during the render without us having to
|
||||
// touch or guess neutral values for them.
|
||||
struct Snap
|
||||
{
|
||||
MediaTrack* track;
|
||||
double fxen;
|
||||
double vol;
|
||||
double pan;
|
||||
double width;
|
||||
double panlaw;
|
||||
double panmode;
|
||||
};
|
||||
std::vector<Snap> snapshots_;
|
||||
|
||||
// Snapshot every neutralized param once per track (dedup: an ancestor shared by
|
||||
// two selected tracks must be restored to its ORIGINAL values, not to a
|
||||
// re-snapshot of the already-neutralized state), then read ALL originals, push
|
||||
// one Snap, and set all to neutral — bypass FX, unity gain, uncolored pan chain.
|
||||
void bypass(MediaTrack* tr)
|
||||
{
|
||||
for (const Snap& s : snapshots_) if (s.track == tr) return; // already done
|
||||
// Read ALL originals first (atomic snapshot), then push, then neutralize.
|
||||
const double fxen = GetMediaTrackInfo_Value(tr, "I_FXEN");
|
||||
const double vol = GetMediaTrackInfo_Value(tr, "D_VOL");
|
||||
const double pan = GetMediaTrackInfo_Value(tr, "D_PAN");
|
||||
const double width = GetMediaTrackInfo_Value(tr, "D_WIDTH");
|
||||
const double panlaw = GetMediaTrackInfo_Value(tr, "D_PANLAW");
|
||||
const double panmode = GetMediaTrackInfo_Value(tr, "I_PANMODE");
|
||||
snapshots_.push_back({tr, fxen, vol, pan, width, panlaw, panmode});
|
||||
SetMediaTrackInfo_Value(tr, "I_FXEN", 0.0); // 0 = bypassed (SDK ~2194)
|
||||
SetMediaTrackInfo_Value(tr, "D_VOL", 1.0); // 1.0 = unity gain (SDK ~2226)
|
||||
SetMediaTrackInfo_Value(tr, "I_PANMODE", 5.0); // 5 = stereo pan (SDK ~2231)
|
||||
SetMediaTrackInfo_Value(tr, "D_PAN", 0.0); // 0.0 = center (SDK ~2227)
|
||||
SetMediaTrackInfo_Value(tr, "D_WIDTH", 1.0); // 1.0 = full width (SDK ~2228)
|
||||
SetMediaTrackInfo_Value(tr, "D_PANLAW", 1.0); // 1.0 = +0dB, no law (SDK ~2232)
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
// Renders one CaptureRequest through the offline backend under the scope's
|
||||
// FX-bypass guard, returning the backend's CaptureResult. Shared by RunCapture and
|
||||
// RunRecaptureFromSource so the FX-scope neutralize + render recipe lives in ONE
|
||||
// place: the out-of-scope FX / fader / pan chain is snapshotted, neutralized for the
|
||||
// render, and fully restored on every path (RAII). Non-destructive; touches no
|
||||
// timeline item (load-bearing principle) — it writes a file only.
|
||||
CaptureResult renderOffline(CaptureScope scope,
|
||||
const std::vector<MediaTrack*>& sourceTracks,
|
||||
const CaptureRequest& req)
|
||||
{
|
||||
ReaProject* proj = EnumProjects(-1, nullptr, 0);
|
||||
FxBypassGuard fxGuard(scope, sourceTracks, proj);
|
||||
OfflineRenderBackend backend;
|
||||
return backend.capture(req);
|
||||
}
|
||||
|
||||
// Renders ONE capture request under the scope's FX-bypass guard, stamps provenance,
|
||||
// and adds the resulting Sample to the ACTIVE bank + records the created file in the
|
||||
// owned-file manifest — WITHOUT persisting. The caller persists once (single-capture:
|
||||
// right after; batch: once at the end) so a batch does not write ext state N times.
|
||||
//
|
||||
// Provenance is read from the LIVE selection here, so a batch that transiently
|
||||
// selects exactly one item per unit gets per-unit-correct provenance. `src` supplies
|
||||
// the source tracks (FX bypass + Sample GUIDs); `scope` drives the bypass plan and
|
||||
// provenance scope. Returns the backend's CaptureResult (status + message) so the
|
||||
// caller can report success/failure. Load-bearing principle holds: writes a file +
|
||||
// a bank index entry ONLY; never touches the arrange/timeline. Non-destructive: the
|
||||
// out-of-scope FX/fader/pan chain is fully restored on every path (FxBypassGuard),
|
||||
// and the backend restores every RENDER_* setting.
|
||||
//
|
||||
// On success, res.sample.id carries the LANDED bank-index id (S8): the newly-added id
|
||||
// on a fresh add, or the EXISTING entry's id on a hash-dedup collapse — so the S8
|
||||
// capture+assign path can target the sample actually in the bank. Batch callers ignore
|
||||
// it; the plain capture actions are unaffected.
|
||||
CaptureResult captureAndIndexOne(ReaSamplerSession& session,
|
||||
CaptureScope scope,
|
||||
const ResolvedSource& src,
|
||||
const std::string& baseName,
|
||||
double startSeconds,
|
||||
double endSeconds)
|
||||
{
|
||||
// The tail mode is a PANEL SETTING (docked bank panel's toggle), not a per-action
|
||||
// variant: the capture actions apply whatever the panel is set to. Default is None
|
||||
// (exact bounds / byte-identical to today) until the user opts in via the toggle.
|
||||
const TailSetting tail = bankPanelTailSetting();
|
||||
|
||||
CaptureRequest req;
|
||||
req.sourceMode = sourceModeForScope(scope);
|
||||
req.startSeconds = startSeconds; // exact bounds — no rounding
|
||||
req.endSeconds = endSeconds;
|
||||
req.wetDry = 1.0; // wet post the FX left enabled by the scope
|
||||
req.tailMode = tail.mode; // None / Auto / Manual, from the panel toggle
|
||||
req.tailMs = tail.manualMs; // Manual-only (clamped); ignored for None/Auto
|
||||
req.sampleRate = 0; // follow project rate
|
||||
req.channelCount = 2;
|
||||
req.bitDepth = WavBitDepth::Float32; // deterministic, no dither
|
||||
req.baseName = baseName;
|
||||
req.trackGuids = src.trackGuids; // recorded on the Sample (provenance)
|
||||
|
||||
// M10: compute provenance BEFORE the FxBypassGuard neutralizes the in-scope chain —
|
||||
// the source FX-chain identity must be read from the LIVE (un-bypassed) chain, and
|
||||
// the source selection is still live here. Returns nullopt unless this capture
|
||||
// genuinely resamples from a bank sample (detectParent). Read-only.
|
||||
const std::optional<model::Provenance> prov =
|
||||
buildCaptureProvenance(session.book(), req, scope, src);
|
||||
|
||||
// Render under the scope's FX-bypass guard (out-of-scope FX / fader / pan chain
|
||||
// neutralized for the render, fully restored on every path). Writes a file only.
|
||||
CaptureResult res = renderOffline(scope, src.sourceTracks, req);
|
||||
if (res.status != CaptureStatus::Ok)
|
||||
return res;
|
||||
|
||||
// Stamp provenance onto the captured Sample (only set when this was a genuine
|
||||
// resample-from-sample; otherwise the optional stays empty, per M1's contract).
|
||||
res.sample.provenance = prov;
|
||||
|
||||
// Add to the ACTIVE bank: session.bank() resolves to book.activeIndex() (B2). The
|
||||
// AddResult tells a fresh add from a hash-dedup collapse, so the assign path (S8) can
|
||||
// target the sample actually in the bank (the existing entry on a collapse).
|
||||
const model::AddResult addResult = session.bank().add(res.sample);
|
||||
// B-cap: record the created file in the owned-file manifest, at the same point the
|
||||
// Sample is added. Recorded regardless of the index AddResult — even a hash-collapse
|
||||
// still WROTE a file the tool owns, and the manifest dedups a repeat path itself
|
||||
// (Phase R prune reconciles manifest vs index later).
|
||||
session.owned().add(res.sample.relativePath);
|
||||
|
||||
// Resolve the LANDED bank-index id into res.sample.id for the S8 assign path: the new
|
||||
// id on a fresh Added (already in res.sample.id); the EXISTING entry's id on a
|
||||
// Collapsed (the file we just rendered deduped onto an already-present sample — assign
|
||||
// THAT one). Batch/plain-capture callers ignore this field; behaviour unchanged.
|
||||
if (addResult == model::AddResult::Collapsed && !res.sample.contentHash.empty())
|
||||
{
|
||||
if (const model::Sample* existing =
|
||||
session.bank().findByHash(res.sample.contentHash))
|
||||
res.sample.id = existing->id;
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
// Runs one capture-action-table row: resolve its scope source + range, render + add +
|
||||
// record via captureAndIndexOne, then persist + mark dirty. The load-bearing principle
|
||||
// holds structurally — this path writes a file + a bank index entry ONLY; it never
|
||||
// calls InsertMedia or touches the arrange/timeline.
|
||||
// Returns the bank-index id of the sample the capture landed on: the newly-added id on a
|
||||
// fresh capture, or the EXISTING id on a hash-dedup collapse (so an ingest-with-assign
|
||||
// targets the sample actually in the bank). Empty on any failure / no-op. The S8 arrange
|
||||
// capture+assign path reads this to write an assignment request; the plain capture actions
|
||||
// ignore it (their behaviour is unchanged — capture still writes a file + index entry only).
|
||||
std::string RunCapture(ReaSamplerSession& session, const CaptureActionDef& def)
|
||||
{
|
||||
ResolvedSource src;
|
||||
std::string why;
|
||||
if (!ResolveScopeSource(def.scope, src, why))
|
||||
{
|
||||
ShowConsoleMsg(("ReaSampler capture: " + why + ".\n").c_str());
|
||||
return {};
|
||||
}
|
||||
|
||||
CaptureResult res = captureAndIndexOne(session, def.scope, src, def.baseName,
|
||||
src.startSeconds, src.endSeconds);
|
||||
if (res.status != CaptureStatus::Ok)
|
||||
{
|
||||
ShowConsoleMsg(("ReaSampler capture failed: " + res.message + "\n").c_str());
|
||||
return {};
|
||||
}
|
||||
|
||||
// captureAndIndexOne has already stamped provenance, added the Sample to the ACTIVE
|
||||
// bank, and recorded the created file in the owned-file manifest (WITHOUT persisting).
|
||||
// Persist the updated book AND manifest into the active project's ext state (the
|
||||
// `banks` + `owned_files` keys) so the capture survives Save / close+reopen (M4) and
|
||||
// travels with the .rpp. saveToActiveProject also clears the retired legacy key and
|
||||
// calls MarkProjectDirty. Non-destructive: writes only our own ext-state keys.
|
||||
// S9: a capture add is a bank-content change -> bump before the persist so an assigned
|
||||
// live instance refreshes hands-free (the S8 capture+assign path builds on this).
|
||||
session.bumpBankGeneration();
|
||||
session.saveToActiveProject();
|
||||
|
||||
// Hand the LANDED bank-index id back to the assign path (S8): captureAndIndexOne
|
||||
// resolved res.sample.id to the fresh id on a new add or the existing entry's id on a
|
||||
// hash-dedup collapse. Empty on any reject (unreachable here — status was Ok above).
|
||||
return res.sample.id;
|
||||
}
|
||||
|
||||
// S8 arrange ingest: capture the selected item / time-selection into the active bank
|
||||
// (reusing the Item-scope capture path verbatim) and, on success, write an assignment
|
||||
// request so the active sampler instance plays the new sample on its next reload. The
|
||||
// capture itself is unchanged — RunCapture writes a file + an index entry and NEVER
|
||||
// inserts a timeline item (load-bearing principle); the only addition here is the
|
||||
// bank-index-id -> assignment-request write after the sample lands. If the capture
|
||||
// failed / no-op'd (empty id), no assignment is written (nothing to assign).
|
||||
//
|
||||
// UNDO GROUPING: both the bank mutation (RunCapture -> saveToActiveProject) AND the
|
||||
// assignment-request write (ingestAssignActiveInstance -> writeAssignmentRequest) are
|
||||
// wrapped in a single undo block so Ctrl-Z rolls back both ext-state keys atomically.
|
||||
// An undo that removes the captured sample also clears the assign_request that named it,
|
||||
// preventing a stale request from pointing at a removed sample. The block uses the house
|
||||
// pattern (UNDO_STATE_MISCCFG, discarded on an unsaved project with empty label + zero
|
||||
// flag) matching the bank-op family in actions.cpp.
|
||||
void RunCaptureItemAssign(ReaSamplerSession& session)
|
||||
{
|
||||
// Reuse the Item-scope def from the capture table (index 0) — same range logic, same
|
||||
// FX-scope neutralize, same bank/persist landing as the plain "capture item" action.
|
||||
Undo_BeginBlock2(nullptr);
|
||||
|
||||
const std::string sampleId = RunCapture(session, captureActionTable()[0]);
|
||||
if (sampleId.empty())
|
||||
{
|
||||
// Capture failed or no-op'd — RunCapture already reported. Discard the empty point.
|
||||
Undo_EndBlock2(nullptr, "", 0);
|
||||
return;
|
||||
}
|
||||
|
||||
// Assign inside the same block so undo clears both keys together.
|
||||
ingestAssignActiveInstance(session.book().activeBankId(), sampleId);
|
||||
Undo_EndBlock2(nullptr, "ReaSampler: capture + assign to active instance",
|
||||
UNDO_STATE_MISCCFG);
|
||||
|
||||
bankPanelRefresh();
|
||||
ShowConsoleMsg("ReaSampler ingest: captured into the bank and assigned to the active "
|
||||
"instance.\n");
|
||||
}
|
||||
|
||||
// STARTS the REALTIME track capture and returns immediately — the record runs across
|
||||
// timer ticks (DriveRealtimeCapture in realtime_lifecycle), so REAPER's UI stays
|
||||
// responsive. Resolves the selected tracks + the range (razor-else-time, the same
|
||||
// orthogonal range logic as the offline scopes) and starts recording each selected
|
||||
// track's OWN output into a hidden temp track via RealtimeRecordBackend::begin (a
|
||||
// send FROM each source track INTO the temp — see capture_realtime_shell.cpp §TAP);
|
||||
// OnTimer drives it to completion, then adds the Sample and persists. TRACK scope
|
||||
// only this increment (item realtime is deferred). Dialog-free. Non-bit-identical
|
||||
// by nature (it is realtime) — offline stays the deterministic default.
|
||||
// FxBypassGuard is NOT used here — the track-output tap is PRE-parent by
|
||||
// construction (§TAP), so there is no live chain to neutralize. The load-bearing
|
||||
// principle holds structurally — this writes a file + a bank entry ONLY; the temp
|
||||
// track is a transient sink removed by the backend, nothing lands in arrange.
|
||||
//
|
||||
// A SECOND realtime capture requested while one is in progress is REJECTED — the
|
||||
// first keeps running (we own the transport for its window; starting a second would
|
||||
// collide on the transport and the temp-track/arm snapshot).
|
||||
void RunCaptureRealtimeTrack(ReaSamplerSession& session)
|
||||
{
|
||||
(void)session; // start path persists nothing — commit happens on the terminal tick
|
||||
if (g_rtCapture)
|
||||
{
|
||||
ShowConsoleMsg("ReaSampler realtime capture: a capture is already in "
|
||||
"progress -- let it finish (or stop the transport) first.\n");
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve the selected tracks + range exactly as the offline Track scope does.
|
||||
// No track selected -> refuse (same no-op as offline track scope).
|
||||
ResolvedSource src;
|
||||
std::string why;
|
||||
if (!ResolveScopeSource(CaptureScope::Track, src, why))
|
||||
{
|
||||
ShowConsoleMsg(("ReaSampler realtime capture: " + why + ".\n").c_str());
|
||||
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 TailSetting tail = bankPanelTailSetting();
|
||||
|
||||
CaptureRequest req;
|
||||
req.sourceMode = 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 = 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 = WavBitDepth::Float32;
|
||||
req.baseName = "realtime";
|
||||
req.trackGuids = src.trackGuids; // provenance on the Sample
|
||||
|
||||
CaptureResult failure;
|
||||
RealtimeCaptureHandle st = g_rtBackend.begin(req, src.sourceTracks, failure);
|
||||
if (!st)
|
||||
{
|
||||
// begin() validated/failed and already restored anything it touched.
|
||||
ShowConsoleMsg(("ReaSampler realtime capture failed: " + failure.message + "\n").c_str());
|
||||
return;
|
||||
}
|
||||
|
||||
// Started. Store the in-flight state + its project; OnTimer drives it to
|
||||
// completion across ticks (UI stays responsive).
|
||||
g_rtCaptureProject = EnumProjects(-1, nullptr, 0);
|
||||
g_rtCapture = std::move(st);
|
||||
}
|
||||
|
||||
// Cancels the in-flight realtime capture on demand (bindable action). Force-terminates
|
||||
// via abort() — stop the transport + restore ALL snapshotted state (non-destructive),
|
||||
// committing whatever audio was already captured (best effort) so a cancel near the end
|
||||
// still keeps the take. Runs only against the record's OWN project (abort() self-guards
|
||||
// the closed-project case, review §1). No-op with a note when nothing is in flight.
|
||||
void RunCancelRealtime(ReaSamplerSession& session)
|
||||
{
|
||||
if (!g_rtCapture)
|
||||
{
|
||||
ShowConsoleMsg("ReaSampler: no realtime capture in progress to cancel.\n");
|
||||
return;
|
||||
}
|
||||
RealtimeTickResult r = g_rtBackend.abort(*g_rtCapture);
|
||||
if (r.status == RealtimeTickStatus::Done)
|
||||
CommitRealtimeResult(session, r.result); // Ok: keep what was captured up to the cancel
|
||||
else
|
||||
ShowConsoleMsg(("ReaSampler realtime capture cancelled -- " +
|
||||
r.result.message + "\n").c_str());
|
||||
g_rtCapture.reset();
|
||||
g_rtCaptureProject = nullptr;
|
||||
}
|
||||
|
||||
// Runs the M6 insert: place the bank panel's selected sample(s) at the edit cursor
|
||||
// via InsertMedia, undo-wrapped. `conform` selects the explicit opt-in tempo-match
|
||||
// variant (never silent — it fires only from the distinct "conform" action). This
|
||||
// is the INTENDED placement path: it adds items to the arrange on purpose
|
||||
// (CONTEXT.md §load-bearing principle) and runs only from a user-invoked action.
|
||||
void RunInsertSelected(ReaSamplerSession& session, bool conform)
|
||||
{
|
||||
InsertRequest req;
|
||||
// target defaults to CurrentTrack (InsertOptions::target) — inserts onto the
|
||||
// user's currently-selected track(s) at the edit cursor.
|
||||
req.options.conform = conform ? TempoConform::Ratio1x : TempoConform::None;
|
||||
// preservePitch stays true: a tempo conform matches tempo without varispeeding
|
||||
// pitch. (A pitch-shifting variant is a later opt-in if wanted — YAGNI now.)
|
||||
|
||||
InsertResult res = runInsert(&session, req);
|
||||
|
||||
switch (res.status)
|
||||
{
|
||||
case InsertStatus::Ok:
|
||||
break; // success — no console chatter
|
||||
case InsertStatus::NoSelection:
|
||||
// "select a track first" is printed by runInsert when no track is
|
||||
// selected; this branch covers the no-panel-selection case.
|
||||
ShowConsoleMsg("ReaSampler insert: nothing selected in the bank panel.\n");
|
||||
break;
|
||||
case InsertStatus::NoProject:
|
||||
ShowConsoleMsg("ReaSampler insert: no saved project, so the bank has no location.\n");
|
||||
break;
|
||||
case InsertStatus::NothingResolved:
|
||||
ShowConsoleMsg("ReaSampler insert: selected sample(s) could not be resolved to a file.\n");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace reasampler::capture
|
||||
@@ -0,0 +1,73 @@
|
||||
#pragma once
|
||||
// capture_orchestrator — the single-capture orchestration + the realtime/insert
|
||||
// action bodies (Q-W3 hoist out of main.cpp, T4-02). Owns:
|
||||
// * renderOffline — ONE offline render under the scope's FxBypassGuard (the
|
||||
// stack-RAII out-of-scope FX/fader/pan neutralize, defined in the .cpp —
|
||||
// precision-invariant-critical, shared by single-shot / batch / recapture);
|
||||
// * captureAndIndexOne — render + provenance stamp + bank add + owned-manifest
|
||||
// record, WITHOUT persisting (single-shot persists right after; batch persists
|
||||
// once at the end);
|
||||
// * RunCapture / RunCaptureItemAssign — the bindable single-capture actions;
|
||||
// * RunCaptureRealtimeTrack / RunCancelRealtime — the realtime action bodies
|
||||
// (the in-flight state itself lives in realtime_lifecycle);
|
||||
// * RunInsertSelected — the M6 placement action body (the INTENDED, explicit
|
||||
// placement path — the one deliberate exception to capture-never-places).
|
||||
//
|
||||
// The session is threaded explicitly (no hidden module state): main.cpp's dispatch
|
||||
// passes its ReaSamplerSession. The load-bearing principle holds structurally —
|
||||
// no capture path here calls InsertMedia or touches the arrange/timeline; only
|
||||
// RunInsertSelected places, on purpose, via the insert shell.
|
||||
//
|
||||
// REAPER-facing: the .cpp includes reaper_plugin_functions.h WITHOUT
|
||||
// REAPERAPI_IMPLEMENT (main.cpp owns the API pointers — CLAUDE.md §contract).
|
||||
|
||||
#include <string>
|
||||
|
||||
#include "shell/capture/capture.h" // CaptureResult / CaptureRequest
|
||||
#include "shell/capture/scope_resolve.h" // ResolvedSource
|
||||
#include "core/capture/render_settings.h" // CaptureScope, CaptureActionDef
|
||||
|
||||
namespace reasampler {
|
||||
class ReaSamplerSession;
|
||||
}
|
||||
|
||||
namespace reasampler::capture {
|
||||
|
||||
// Renders one CaptureRequest through the offline backend under the scope's
|
||||
// FX-bypass guard, returning the backend's CaptureResult. Shared by RunCapture and
|
||||
// RunRecaptureFromSource so the FX-scope neutralize + render recipe lives in ONE
|
||||
// place. Non-destructive; touches no timeline item — it writes a file only.
|
||||
CaptureResult renderOffline(CaptureScope scope,
|
||||
const std::vector<MediaTrack*>& sourceTracks,
|
||||
const CaptureRequest& req);
|
||||
|
||||
// Renders ONE capture request under the scope's FX-bypass guard, stamps provenance,
|
||||
// and adds the resulting Sample to the ACTIVE bank + records the created file in
|
||||
// the owned-file manifest — WITHOUT persisting. On success, res.sample.id carries
|
||||
// the LANDED bank-index id (fresh add or hash-dedup collapse target — S8).
|
||||
CaptureResult captureAndIndexOne(ReaSamplerSession& session,
|
||||
CaptureScope scope,
|
||||
const ResolvedSource& src,
|
||||
const std::string& baseName,
|
||||
double startSeconds,
|
||||
double endSeconds);
|
||||
|
||||
// Runs one capture-action-table row: resolve, render + add + record, persist +
|
||||
// mark dirty. Returns the landed bank-index id ("" on failure/no-op) — the S8
|
||||
// capture+assign path consumes it; the plain capture actions ignore it.
|
||||
std::string RunCapture(ReaSamplerSession& session, const CaptureActionDef& def);
|
||||
|
||||
// S8 arrange ingest: Item-scope capture into the active bank + assignment-request
|
||||
// write, in one undo block.
|
||||
void RunCaptureItemAssign(ReaSamplerSession& session);
|
||||
|
||||
// STARTS the realtime track capture (async, timer-driven — the in-flight state is
|
||||
// realtime_lifecycle's; OnTimer drives it) / cancels the in-flight one.
|
||||
void RunCaptureRealtimeTrack(ReaSamplerSession& session);
|
||||
void RunCancelRealtime(ReaSamplerSession& session);
|
||||
|
||||
// Runs the M6 insert: place the bank panel's selected sample(s) at the edit cursor
|
||||
// via the insert shell. `conform` selects the explicit opt-in tempo-match variant.
|
||||
void RunInsertSelected(ReaSamplerSession& session, bool conform);
|
||||
|
||||
} // namespace reasampler::capture
|
||||
@@ -0,0 +1,252 @@
|
||||
// capture_realtime_finalize.cpp — the FILE-SIDE half of the realtime-record shell
|
||||
// (Q-W3, T4-08 split): recorded-file discovery, move-into-bank, the Auto-tail PCM
|
||||
// decay-scan trim, and the finished-Sample population. See the header. The async
|
||||
// record lifecycle lives in capture_realtime_shell.cpp.
|
||||
//
|
||||
// Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.h
|
||||
// WITHOUT REAPERAPI_IMPLEMENT — main.cpp is the one TU that defines the API
|
||||
// pointers; here they are extern (CLAUDE.md §contract).
|
||||
|
||||
#include "shell/capture/capture_realtime_finalize.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "core/audio/peaks.h" // lastFrameAboveThreshold
|
||||
#include "core/capture/capture_realtime.h" // RecordedCapture, sampleFromRecordedCapture
|
||||
#include "core/capture/render_settings.h" // autoTrimEndRatio
|
||||
#include "core/capture/wav_codec.h" // parseWavLayout, extractFloatFrames, planWavTruncate, patchU32LE
|
||||
#include "core/util/file_bytes.h" // shared whole-file loader (Q-W1, T2-03)
|
||||
|
||||
#define REAPERAPI_MINIMAL
|
||||
#define REAPERAPI_WANT_GetTrackNumMediaItems
|
||||
#define REAPERAPI_WANT_GetTrackMediaItem
|
||||
#define REAPERAPI_WANT_GetMediaItemTake
|
||||
#define REAPERAPI_WANT_GetMediaItemTake_Source
|
||||
#define REAPERAPI_WANT_GetMediaSourceFileName
|
||||
#include "reaper_plugin_functions.h"
|
||||
|
||||
namespace reasampler::capture {
|
||||
|
||||
namespace {
|
||||
|
||||
std::string normSlashes(std::string s) {
|
||||
for (char& c : s) if (c == '\\') c = '/';
|
||||
if (s.size() > 1 && s.back() == '/') s.pop_back();
|
||||
return s;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// §TAIL — Auto-mode PCM decay-scan trim (docs/product/capture-tail.md §realtime)
|
||||
// ============================================================================
|
||||
// After the recorded file is stable and moved into the bank (the file we OWN — never
|
||||
// the project), Auto mode trims the trailing decay: read the WAV, scan the tail
|
||||
// region (frames AFTER the original range end) backward for the last frame above
|
||||
// -72 dB, and truncate the file there. Rules (spec):
|
||||
// * no frame in the tail window above -72 dB -> trim back to the original range end
|
||||
// * signal never falls below -72 dB in window -> keep the full window (cap did its job)
|
||||
// * otherwise -> trim one frame past the last audible
|
||||
//
|
||||
// Returns the trimmed length in SECONDS (for the Sample), or a negative value to
|
||||
// signal "no trim applied" (caller keeps the pre-trim length). Best-effort and
|
||||
// non-fatal: any unreadable/unknown/short file skips the trim (keeps the full window)
|
||||
// rather than risk corrupting the capture — realtime tail is a convenience path.
|
||||
//
|
||||
// FORMAT / FLUSH ASSUMPTIONS (DAW-verify): the recorded file is a canonical 32-bit
|
||||
// float WAV (REAPER project record format — the manual procedure sets it) and is fully
|
||||
// flushed/closed before this runs (the tick() Finalizing size-stable wait guarantees
|
||||
// that for the normal path; abort()'s best-effort finalize races it, documented).
|
||||
double trimAutoTailInPlace(const std::string& path,
|
||||
double rangeStartSeconds,
|
||||
double rangeEndSeconds) {
|
||||
constexpr double kNoTrim = -1.0;
|
||||
|
||||
std::vector<std::uint8_t> bytes = util::readFileBytes(path);
|
||||
if (bytes.empty()) return kNoTrim;
|
||||
|
||||
const WavLayout layout = parseWavLayout(bytes);
|
||||
if (!layout.valid || layout.sampleRate == 0) return kNoTrim; // not a WAV we trim
|
||||
|
||||
const std::size_t totalFrames = layout.frameCount();
|
||||
if (totalFrames == 0) return kNoTrim;
|
||||
|
||||
// The original range end as a frame index within the file (frame 0 == start). Use
|
||||
// the FILE's own sample rate (authoritative) — the request rate may be 0 (=follow
|
||||
// project). Clamp to the file so a rounding overshoot cannot exceed it.
|
||||
const double rangeSeconds = rangeEndSeconds - rangeStartSeconds;
|
||||
if (rangeSeconds <= 0.0) return kNoTrim;
|
||||
std::size_t rangeEndFrame = static_cast<std::size_t>(
|
||||
rangeSeconds * static_cast<double>(layout.sampleRate) + 0.5);
|
||||
if (rangeEndFrame > totalFrames) rangeEndFrame = totalFrames;
|
||||
|
||||
// Nothing recorded past the range end (the tail window was empty) -> nothing to
|
||||
// trim; keep as-is. (Shouldn't happen for Auto, but total by construction.)
|
||||
if (rangeEndFrame >= totalFrames) return kNoTrim;
|
||||
|
||||
// Scan ONLY the tail region (frames after the original range end). The trim never
|
||||
// eats into the range body — the scan starts at rangeEndFrame.
|
||||
const std::size_t tailFrames = totalFrames - rangeEndFrame;
|
||||
const std::vector<AudioSample> tailPcm =
|
||||
extractFloatFrames(bytes, layout, rangeEndFrame, tailFrames);
|
||||
if (tailPcm.empty()) return kNoTrim;
|
||||
|
||||
const float threshold = static_cast<float>(autoTrimEndRatio());
|
||||
const std::size_t lastAbove = audio::lastFrameAboveThreshold(
|
||||
tailPcm, layout.channelCount, tailFrames, threshold);
|
||||
|
||||
// keptFrames: the total frame count the trimmed file retains.
|
||||
// no audible tail frame -> trim back to the range end (rangeEndFrame frames)
|
||||
// an audible frame at idx -> keep range body + up to and including that frame
|
||||
// The "signal never falls below threshold" case falls out naturally: lastAbove is
|
||||
// the final tail frame, so keptFrames == totalFrames (the full window is kept).
|
||||
std::size_t keptFrames;
|
||||
if (lastAbove == audio::kNoFrameAboveThreshold) {
|
||||
keptFrames = rangeEndFrame;
|
||||
} else {
|
||||
keptFrames = rangeEndFrame + (lastAbove + 1);
|
||||
}
|
||||
if (keptFrames >= totalFrames) return kNoTrim; // full window kept -> no truncate
|
||||
|
||||
const WavTruncatePlan plan = planWavTruncate(layout, keptFrames);
|
||||
if (!plan.valid) return kNoTrim;
|
||||
|
||||
// Patch the RIFF + data size fields in the in-memory buffer so they describe the
|
||||
// kept frame count (wav_codec's patch primitive — the one RIFF owner), then
|
||||
// rewrite the file as exactly the first newFileByteLength bytes (header +
|
||||
// patched sizes + retained PCM). A single truncating write is the simplest
|
||||
// correct truncate — no separate resize step, no partial-write window where the
|
||||
// on-disk sizes and length disagree. The result is a valid, playable WAV of the
|
||||
// kept frames (verified by the wav_codec re-parse test).
|
||||
patchU32LE(bytes, plan.dataSizeFieldOffset, plan.newDataSize);
|
||||
patchU32LE(bytes, plan.riffSizeFieldOffset, plan.newRiffSize);
|
||||
|
||||
// NOTE (DAW-verify, best-effort): a truncating write that fails MID-write (a full
|
||||
// disk, a yanked drive) would leave a short file while we return kNoTrim, so the
|
||||
// Sample length would overstate the file. Vanishingly unlikely for a just-recorded
|
||||
// local bank file, and realtime tail is a convenience path, so a temp-file+atomic-
|
||||
// rename is not warranted here; flagged rather than built.
|
||||
std::ofstream out(path, std::ios::binary | std::ios::trunc);
|
||||
if (!out) return kNoTrim; // could not reopen to rewrite — leave the full file
|
||||
out.write(reinterpret_cast<const char*>(bytes.data()),
|
||||
static_cast<std::streamsize>(plan.newFileByteLength));
|
||||
if (!out) return kNoTrim;
|
||||
out.close();
|
||||
|
||||
// The trimmed length in seconds for the Sample metadata.
|
||||
return static_cast<double>(keptFrames) / static_cast<double>(layout.sampleRate);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::string recordedFilePath(MediaTrack* temp) {
|
||||
if (!temp) return {};
|
||||
if (GetTrackNumMediaItems(temp) <= 0) return {};
|
||||
MediaItem* item = GetTrackMediaItem(temp, 0);
|
||||
if (!item) return {};
|
||||
MediaItem_Take* take = GetMediaItemTake(item, 0);
|
||||
if (!take) return {};
|
||||
PCM_source* src = GetMediaItemTake_Source(take);
|
||||
if (!src) return {};
|
||||
std::vector<char> buf(4096, '\0');
|
||||
GetMediaSourceFileName(src, buf.data(), static_cast<int>(buf.size()));
|
||||
return normSlashes(std::string(buf.data()));
|
||||
}
|
||||
|
||||
CaptureResult finalizeRecording(ReaProject* proj, MediaTrack* temp,
|
||||
const CaptureRequest& request,
|
||||
const BankPaths& paths,
|
||||
const std::string& uniqueTag,
|
||||
double recordWindowEnd) {
|
||||
CaptureResult result;
|
||||
|
||||
const std::string recorded = recordedFilePath(temp);
|
||||
if (recorded.empty() || !std::filesystem::exists(recorded)) {
|
||||
result.status = CaptureStatus::RenderFailed;
|
||||
result.message = "Realtime record produced no file (check transport/record "
|
||||
"settings in the DAW).";
|
||||
return result;
|
||||
}
|
||||
|
||||
std::error_code ec;
|
||||
std::filesystem::create_directories(paths.absoluteDir, ec);
|
||||
const std::string destPath = paths.absoluteDir + "/" + paths.fileName;
|
||||
std::filesystem::rename(recorded, destPath, ec);
|
||||
if (ec) {
|
||||
// Cross-volume rename can fail; fall back to copy+remove.
|
||||
ec.clear();
|
||||
std::filesystem::copy_file(
|
||||
recorded, destPath,
|
||||
std::filesystem::copy_options::overwrite_existing, ec);
|
||||
if (ec) {
|
||||
result.status = CaptureStatus::RenderFailed;
|
||||
result.message = "Recorded file could not be moved into the bank: " +
|
||||
ec.message();
|
||||
return result;
|
||||
}
|
||||
std::error_code rmEc;
|
||||
std::filesystem::remove(recorded, rmEc); // best-effort
|
||||
}
|
||||
|
||||
// TAIL (Auto): trim the trailing decay of the recorded window in place — on the
|
||||
// BANK file we now own (destPath), never the project. Best-effort: an unreadable /
|
||||
// unknown-format / short file skips the trim (keeps the full window) rather than
|
||||
// corrupt the capture. Only Auto trims; None recorded exact bounds and Manual is a
|
||||
// fixed window (spec §The realtime path). Returns the trimmed length in seconds,
|
||||
// or < 0 for "no trim applied".
|
||||
double trimmedLenSeconds = -1.0;
|
||||
if (request.tailMode == TailMode::Auto) {
|
||||
trimmedLenSeconds = trimAutoTailInPlace(destPath,
|
||||
request.startSeconds,
|
||||
request.endSeconds);
|
||||
}
|
||||
|
||||
// The pure recorded-capture -> Sample mapping (identity, bounds echo, tier).
|
||||
RecordedCapture cap;
|
||||
cap.relativePath = paths.relativePath;
|
||||
cap.uniqueTag = uniqueTag;
|
||||
cap.sourceMode = SourceMode::Realtime;
|
||||
cap.startSeconds = request.startSeconds;
|
||||
cap.endSeconds = request.endSeconds;
|
||||
cap.wetDry = request.wetDry;
|
||||
cap.displayName = request.baseName;
|
||||
cap.trackGuids = request.trackGuids;
|
||||
cap.channelCount = request.channelCount;
|
||||
|
||||
result.status = CaptureStatus::Ok;
|
||||
result.sample = sampleFromRecordedCapture(cap);
|
||||
|
||||
// The SHARED finished-capture stamp (T2-09 dedupe): trackGuids + channelCount
|
||||
// (request echo), resolved sampleRate (request rate else PROJECT_SRATE — read
|
||||
// against the record's OWN project), captureTempo, the capture-start time
|
||||
// signature (timeSigProj = proj: the realtime path PINS the record's own
|
||||
// project — the divergence from offline's active-project read, kept
|
||||
// caller-visible here), the WAV-aware contentHash of the (possibly trimmed)
|
||||
// bank file, and createdTimestamp.
|
||||
stampCaptureSample(result.sample, request, /*rateProj=*/proj,
|
||||
/*timeSigProj=*/proj, destPath);
|
||||
|
||||
// The recorded file's true length differs from the request range when a tail was
|
||||
// recorded, so the Sample length must reflect the FILE, not the range:
|
||||
// Auto with a trim applied -> the trimmed length trimAutoTailInPlace returned.
|
||||
// Auto with no trim, or Manual -> the full recorded window (end - start).
|
||||
// None -> the exact range (unchanged; recordWindowEnd == endSeconds).
|
||||
// sampleFromRecordedCapture already set lengthSeconds = end - start; override it
|
||||
// to the recorded/trimmed length so downstream (thumbnail, placement) matches disk.
|
||||
if (trimmedLenSeconds >= 0.0) {
|
||||
result.sample.lengthSeconds = trimmedLenSeconds;
|
||||
} else {
|
||||
result.sample.lengthSeconds = recordWindowEnd - request.startSeconds;
|
||||
}
|
||||
|
||||
result.message = "Realtime-captured [" +
|
||||
std::to_string(request.startSeconds) + "s, " +
|
||||
std::to_string(request.endSeconds) + "s] (recorded " +
|
||||
std::to_string(result.sample.lengthSeconds) + "s) -> " +
|
||||
paths.relativePath;
|
||||
return result;
|
||||
}
|
||||
|
||||
} // namespace reasampler::capture
|
||||
@@ -0,0 +1,42 @@
|
||||
#pragma once
|
||||
// capture_realtime_finalize — the FILE-SIDE half of the realtime-record shell
|
||||
// (Q-W3, T4-08 split riding the Q-9 rename): discovering the file REAPER actually
|
||||
// recorded, moving it into the bank, the Auto-tail PCM decay-scan trim, and the
|
||||
// finished-Sample population. The async record LIFECYCLE (state snapshot/restore,
|
||||
// begin/tick/abort) lives in capture_realtime_shell.cpp; this half talks to
|
||||
// wav_codec and the filesystem, not to the transport.
|
||||
//
|
||||
// REAPER-facing: the .cpp includes reaper_plugin_functions.h WITHOUT
|
||||
// REAPERAPI_IMPLEMENT (main.cpp owns the API pointers — CLAUDE.md §contract).
|
||||
// MediaTrack / ReaProject are forward-declared (via capture.h) so this header
|
||||
// stays SDK-lite.
|
||||
|
||||
#include <string>
|
||||
|
||||
#include "shell/capture/capture.h" // CaptureRequest / CaptureResult
|
||||
#include "core/capture/capture_paths.h" // BankPaths
|
||||
|
||||
namespace reasampler::capture {
|
||||
|
||||
// Discovers the file REAPER actually recorded onto the temp track: the first media
|
||||
// item's active take's source file, forward-slashed. Empty string if nothing was
|
||||
// recorded (no item / take / source). Also used by the lifecycle's flush wait
|
||||
// (size-stable check) before finalize runs.
|
||||
std::string recordedFilePath(MediaTrack* temp);
|
||||
|
||||
// Builds a CaptureResult for a finalized recording: discover the recorded file,
|
||||
// move it into the bank at `paths`, Auto-trim the tail decay in place when the
|
||||
// request asks for it, and populate the Sample (pure sampleFromRecordedCapture +
|
||||
// the shared stampCaptureSample — both project reads pinned to `proj`, the
|
||||
// record's OWN project). Returns Ok + Sample on success, or a RenderFailed result.
|
||||
// Does NOT restore any snapshotted state — the caller restores unconditionally
|
||||
// afterward (finalize + restore are separate steps so a finalize failure still
|
||||
// restores). `recordWindowEnd` is the recorded window end in project seconds
|
||||
// (>= request.endSeconds when a tail was recorded) — the untrimmed-length source.
|
||||
CaptureResult finalizeRecording(ReaProject* proj, MediaTrack* temp,
|
||||
const CaptureRequest& request,
|
||||
const BankPaths& paths,
|
||||
const std::string& uniqueTag,
|
||||
double recordWindowEnd);
|
||||
|
||||
} // namespace reasampler::capture
|
||||
+26
-278
@@ -1,5 +1,10 @@
|
||||
#include "core/namespaces.h"
|
||||
// capture_realtime.cpp — REAPER-facing realtime-record backend (RealtimeRecordBackend).
|
||||
// capture_realtime_shell.cpp — REAPER-facing realtime-record backend
|
||||
// (RealtimeRecordBackend): the ASYNC record LIFECYCLE — state snapshot/restore +
|
||||
// begin/tick/abort. (Renamed from capture_realtime.cpp in Q-W3 — the Q-9 naming
|
||||
// rider: the PURE module owns the capture_realtime stem, this shell takes the
|
||||
// suffix, matching drag_out ↔ drag_out_win.) The FILE-SIDE half — recorded-file
|
||||
// discovery, move-into-bank, Auto-tail trim, Sample population — lives in
|
||||
// capture_realtime_finalize.cpp (T4-08 split).
|
||||
//
|
||||
// Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.h
|
||||
// WITHOUT REAPERAPI_IMPLEMENT — main.cpp is the one TU that defines the API
|
||||
@@ -30,8 +35,9 @@
|
||||
// completion, user stop, error, second-capture reject, project switch, unload —
|
||||
// funnels through the SAME single restore, safe to call once from whichever fires.
|
||||
// The pure record-mode bookkeeping, the recorded-file->Sample mapping, and the
|
||||
// completion state machine (advanceRecordPhase) all live in realtime_record.{h,cpp}
|
||||
// (unit-tested outside the DAW). This TU owns only the REAPER-bound recipe.
|
||||
// completion state machine (advanceRecordPhase) all live in the pure
|
||||
// core/capture/capture_realtime.{h,cpp} (unit-tested outside the DAW). This TU
|
||||
// owns only the REAPER-bound lifecycle recipe.
|
||||
//
|
||||
// ============================================================================
|
||||
// §TAP — track-output tap (selected track's own output, PRE-parent)
|
||||
@@ -72,26 +78,18 @@
|
||||
|
||||
#include <chrono>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <ctime>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "core/capture/capture_paths.h" // hashBytes, deriveBankPaths
|
||||
#include "core/util/file_bytes.h" // shared whole-file loader (Q-W1, T2-03)
|
||||
#include "core/audio/peaks.h" // lastFrameAboveThreshold, AudioSample
|
||||
#include "core/capture/realtime_record.h"
|
||||
#include "core/capture/render_settings.h" // autoTrimEndRatio, realtimeRecordWindowEnd
|
||||
#include "core/capture/wav_trim.h" // parseWavLayout, extractFloatFrames, planWavTruncate
|
||||
#include "core/capture/capture_paths.h" // deriveBankPaths
|
||||
#include "core/capture/capture_realtime.h" // RecordPhase machine, record-mode plan (pure)
|
||||
#include "core/capture/render_settings.h" // realtimeRecordWindowEnd
|
||||
#include "shell/capture/capture_realtime_finalize.h" // recordedFilePath, finalizeRecording
|
||||
|
||||
#define REAPERAPI_MINIMAL
|
||||
#define REAPERAPI_WANT_EnumProjects
|
||||
#define REAPERAPI_WANT_Main_SaveProject
|
||||
#define REAPERAPI_WANT_Master_GetTempo
|
||||
#define REAPERAPI_WANT_TimeMap_GetTimeSigAtTime
|
||||
#define REAPERAPI_WANT_GetSetProjectInfo
|
||||
#define REAPERAPI_WANT_InsertTrackAtIndex
|
||||
#define REAPERAPI_WANT_DeleteTrack
|
||||
#define REAPERAPI_WANT_CountTracks
|
||||
@@ -99,11 +97,6 @@
|
||||
#define REAPERAPI_WANT_CreateTrackSend
|
||||
#define REAPERAPI_WANT_GetMediaTrackInfo_Value
|
||||
#define REAPERAPI_WANT_SetMediaTrackInfo_Value
|
||||
#define REAPERAPI_WANT_GetTrackNumMediaItems
|
||||
#define REAPERAPI_WANT_GetTrackMediaItem
|
||||
#define REAPERAPI_WANT_GetMediaItemTake
|
||||
#define REAPERAPI_WANT_GetMediaItemTake_Source
|
||||
#define REAPERAPI_WANT_GetMediaSourceFileName
|
||||
#define REAPERAPI_WANT_CSurf_OnRecord
|
||||
#define REAPERAPI_WANT_OnStopButtonEx
|
||||
#define REAPERAPI_WANT_GetPlayStateEx
|
||||
@@ -114,16 +107,10 @@
|
||||
#define REAPERAPI_WANT_ValidatePtr2
|
||||
#include "reaper_plugin_functions.h"
|
||||
|
||||
namespace reasampler {
|
||||
namespace reasampler::capture {
|
||||
|
||||
namespace {
|
||||
|
||||
// A monotonic, filesystem-safe timestamp tag so repeated captures do not collide.
|
||||
std::string makeUniqueTag() {
|
||||
std::time_t now = std::time(nullptr);
|
||||
return "rt-" + std::to_string(static_cast<long long>(now));
|
||||
}
|
||||
|
||||
std::string normSlashes(std::string s) {
|
||||
for (char& c : s) if (c == '\\') c = '/';
|
||||
if (s.size() > 1 && s.back() == '/') s.pop_back();
|
||||
@@ -138,22 +125,6 @@ std::string readRppPath() {
|
||||
return std::string(buf.data());
|
||||
}
|
||||
|
||||
// Discovers the file REAPER actually recorded onto the temp track: the first media
|
||||
// item's active take's source file. Empty string if nothing was recorded.
|
||||
std::string recordedFilePath(MediaTrack* temp) {
|
||||
if (!temp) return {};
|
||||
if (GetTrackNumMediaItems(temp) <= 0) return {};
|
||||
MediaItem* item = GetTrackMediaItem(temp, 0);
|
||||
if (!item) return {};
|
||||
MediaItem_Take* take = GetMediaItemTake(item, 0);
|
||||
if (!take) return {};
|
||||
PCM_source* src = GetMediaItemTake_Source(take);
|
||||
if (!src) return {};
|
||||
std::vector<char> buf(4096, '\0');
|
||||
GetMediaSourceFileName(src, buf.data(), static_cast<int>(buf.size()));
|
||||
return std::string(buf.data());
|
||||
}
|
||||
|
||||
// The recorded file's current size in bytes, or -1 if it cannot be resolved yet (no
|
||||
// item/take/source, or the file does not exist on disk this tick). Used by the flush
|
||||
// wait to detect stability (size unchanged across a tick) BEFORE moving the file — a
|
||||
@@ -323,236 +294,9 @@ private:
|
||||
bool finalized_ = false;
|
||||
};
|
||||
|
||||
namespace {
|
||||
|
||||
// Whole-file reads go through the shared core/util readFileBytes (Q-W1, T2-03):
|
||||
// empty 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.
|
||||
|
||||
// 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 = readFileBytes(path);
|
||||
if (bytes.empty()) return kNoTrim;
|
||||
|
||||
const reasampler::WavLayout layout = parseWavLayout(bytes);
|
||||
if (!layout.valid || layout.sampleRate == 0) return kNoTrim; // not a WAV we trim
|
||||
|
||||
const std::size_t totalFrames = layout.frameCount();
|
||||
if (totalFrames == 0) return kNoTrim;
|
||||
|
||||
// The original range end as a frame index within the file (frame 0 == start). Use
|
||||
// the FILE's own sample rate (authoritative) — the request rate may be 0 (=follow
|
||||
// project). Clamp to the file so a rounding overshoot cannot exceed it.
|
||||
const double rangeSeconds = rangeEndSeconds - rangeStartSeconds;
|
||||
if (rangeSeconds <= 0.0) return kNoTrim;
|
||||
std::size_t rangeEndFrame = static_cast<std::size_t>(
|
||||
rangeSeconds * static_cast<double>(layout.sampleRate) + 0.5);
|
||||
if (rangeEndFrame > totalFrames) rangeEndFrame = totalFrames;
|
||||
|
||||
// Nothing recorded past the range end (the tail window was empty) -> nothing to
|
||||
// trim; keep as-is. (Shouldn't happen for Auto, but total by construction.)
|
||||
if (rangeEndFrame >= totalFrames) return kNoTrim;
|
||||
|
||||
// Scan ONLY the tail region (frames after the original range end). The trim never
|
||||
// eats into the range body — the scan starts at rangeEndFrame.
|
||||
const std::size_t tailFrames = totalFrames - rangeEndFrame;
|
||||
const std::vector<reasampler::AudioSample> tailPcm =
|
||||
extractFloatFrames(bytes, layout, rangeEndFrame, tailFrames);
|
||||
if (tailPcm.empty()) return kNoTrim;
|
||||
|
||||
const float threshold = static_cast<float>(reasampler::autoTrimEndRatio());
|
||||
const std::size_t lastAbove = reasampler::lastFrameAboveThreshold(
|
||||
tailPcm, layout.channelCount, tailFrames, threshold);
|
||||
|
||||
// keptFrames: the total frame count the trimmed file retains.
|
||||
// no audible tail frame -> trim back to the range end (rangeEndFrame frames)
|
||||
// an audible frame at idx -> keep range body + up to and including that frame
|
||||
// The "signal never falls below threshold" case falls out naturally: lastAbove is
|
||||
// the final tail frame, so keptFrames == totalFrames (the full window is kept).
|
||||
std::size_t keptFrames;
|
||||
if (lastAbove == reasampler::kNoFrameAboveThreshold) {
|
||||
keptFrames = rangeEndFrame;
|
||||
} else {
|
||||
keptFrames = rangeEndFrame + (lastAbove + 1);
|
||||
}
|
||||
if (keptFrames >= totalFrames) return kNoTrim; // full window kept -> no truncate
|
||||
|
||||
const reasampler::WavTruncatePlan plan = planWavTruncate(layout, keptFrames);
|
||||
if (!plan.valid) return kNoTrim;
|
||||
|
||||
// Patch the RIFF + data size fields in the in-memory buffer so they describe the
|
||||
// kept frame count, then rewrite the file as exactly the first newFileByteLength
|
||||
// bytes (header + patched sizes + retained PCM). A single truncating write is the
|
||||
// simplest correct truncate — no separate resize step, no partial-write window
|
||||
// where the on-disk sizes and length disagree. The result is a valid, playable WAV
|
||||
// of the kept frames (verified by the wav_trim re-parse test).
|
||||
writeU32LE(bytes, plan.dataSizeFieldOffset, plan.newDataSize);
|
||||
writeU32LE(bytes, plan.riffSizeFieldOffset, plan.newRiffSize);
|
||||
|
||||
// NOTE (DAW-verify, best-effort): a truncating write that fails MID-write (a full
|
||||
// disk, a yanked drive) would leave a short file while we return kNoTrim, so the
|
||||
// Sample length would overstate the file. Vanishingly unlikely for a just-recorded
|
||||
// local bank file, and realtime tail is a convenience path, so a temp-file+atomic-
|
||||
// rename is not warranted here; flagged rather than built.
|
||||
std::ofstream out(path, std::ios::binary | std::ios::trunc);
|
||||
if (!out) return kNoTrim; // could not reopen to rewrite — leave the full file
|
||||
out.write(reinterpret_cast<const char*>(bytes.data()),
|
||||
static_cast<std::streamsize>(plan.newFileByteLength));
|
||||
if (!out) return kNoTrim;
|
||||
out.close();
|
||||
|
||||
// The trimmed length in seconds for the Sample metadata.
|
||||
return static_cast<double>(keptFrames) / static_cast<double>(layout.sampleRate);
|
||||
}
|
||||
|
||||
// Builds a CaptureResult for a finalized recording: discover the recorded file,
|
||||
// move it into the bank, populate the Sample via the pure mapping. Returns Ok +
|
||||
// Sample on success, or a RenderFailed result. Does NOT restore — the caller
|
||||
// restores unconditionally afterward (finalize + restore are separate steps so a
|
||||
// finalize failure still restores).
|
||||
CaptureResult finalizeRecording(RealtimeCaptureState& st) {
|
||||
CaptureResult result;
|
||||
|
||||
const std::string recorded = normSlashes(recordedFilePath(st.temp_));
|
||||
if (recorded.empty() || !std::filesystem::exists(recorded)) {
|
||||
result.status = CaptureStatus::RenderFailed;
|
||||
result.message = "Realtime record produced no file (check transport/record "
|
||||
"settings in the DAW).";
|
||||
return result;
|
||||
}
|
||||
|
||||
std::error_code ec;
|
||||
std::filesystem::create_directories(st.paths_.absoluteDir, ec);
|
||||
const std::string destPath = st.paths_.absoluteDir + "/" + st.paths_.fileName;
|
||||
std::filesystem::rename(recorded, destPath, ec);
|
||||
if (ec) {
|
||||
// Cross-volume rename can fail; fall back to copy+remove.
|
||||
ec.clear();
|
||||
std::filesystem::copy_file(
|
||||
recorded, destPath,
|
||||
std::filesystem::copy_options::overwrite_existing, ec);
|
||||
if (ec) {
|
||||
result.status = CaptureStatus::RenderFailed;
|
||||
result.message = "Recorded file could not be moved into the bank: " +
|
||||
ec.message();
|
||||
return result;
|
||||
}
|
||||
std::error_code rmEc;
|
||||
std::filesystem::remove(recorded, rmEc); // best-effort
|
||||
}
|
||||
|
||||
// TAIL (Auto): trim the trailing decay of the recorded window in place — on the
|
||||
// BANK file we now own (destPath), never the project. Best-effort: an unreadable /
|
||||
// unknown-format / short file skips the trim (keeps the full window) rather than
|
||||
// corrupt the capture. Only Auto trims; None recorded exact bounds and Manual is a
|
||||
// fixed window (spec §The realtime path). Returns the trimmed length in seconds,
|
||||
// or < 0 for "no trim applied".
|
||||
double trimmedLenSeconds = -1.0;
|
||||
if (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_;
|
||||
cap.sourceMode = SourceMode::Realtime;
|
||||
cap.startSeconds = st.request_.startSeconds;
|
||||
cap.endSeconds = st.request_.endSeconds;
|
||||
cap.wetDry = st.request_.wetDry;
|
||||
cap.displayName = st.request_.baseName;
|
||||
cap.trackGuids = st.request_.trackGuids;
|
||||
cap.channelCount = st.request_.channelCount;
|
||||
cap.sampleRate = (st.request_.sampleRate > 0)
|
||||
? st.request_.sampleRate
|
||||
: static_cast<int>(GetSetProjectInfo(st.proj_, "PROJECT_SRATE", 0.0, false));
|
||||
cap.captureTempo = Master_GetTempo();
|
||||
// Time signature at the record range's START (L7 F1). TimeMap_GetTimeSigAtTime
|
||||
// (reaper_plugin_functions.h:7130) reads the meter effective at that project time;
|
||||
// proj=st.proj_ pins the recording's own project. tempoOut ignored (captureTempo is
|
||||
// the master tempo above). Leaves 0/0 (unstamped) on any failure.
|
||||
{
|
||||
int tsNum = 0, tsDenom = 0;
|
||||
double tsTempo = 0.0;
|
||||
TimeMap_GetTimeSigAtTime(st.proj_, st.request_.startSeconds, &tsNum, &tsDenom, &tsTempo);
|
||||
cap.captureTimeSigNum = tsNum;
|
||||
cap.captureTimeSigDenom = tsDenom;
|
||||
}
|
||||
cap.createdTimestamp = static_cast<std::int64_t>(std::time(nullptr));
|
||||
|
||||
result.status = CaptureStatus::Ok;
|
||||
result.sample = sampleFromRecordedCapture(cap);
|
||||
|
||||
// Content hash: WAV-aware FNV-1a over the (possibly trimmed) bank file's fmt+data
|
||||
// chunks so hashReferencedElsewhere can identify copies in other banks and suppress
|
||||
// the last-reference confirm when another bank still holds the same file. Using
|
||||
// hashWavContent (not the raw hashBytes) skips render-varying metadata chunks
|
||||
// (bext origination timestamp, iXML, LIST/INFO, etc.) so two records of identical
|
||||
// audio collapse to the same hash. Best-effort: an unreadable file leaves
|
||||
// contentHash empty — the safe, confirm-eliciting direction (bank_model treats
|
||||
// "" as non-participating).
|
||||
{
|
||||
const std::vector<std::uint8_t> fileBytes = readFileBytes(destPath);
|
||||
if (!fileBytes.empty()) {
|
||||
result.sample.contentHash = hashWavContent(fileBytes);
|
||||
}
|
||||
}
|
||||
|
||||
// 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] (recorded " +
|
||||
std::to_string(result.sample.lengthSeconds) + "s) -> " +
|
||||
st.paths_.relativePath;
|
||||
return result;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
// The FILE-SIDE finalize half (recorded-file discovery, move-into-bank, the
|
||||
// Auto-tail PCM decay-scan trim, and the finished-Sample population) lives in
|
||||
// capture_realtime_finalize.cpp (T4-08). This TU owns only the async lifecycle.
|
||||
|
||||
// ============================================================================
|
||||
// begin — start the record, snapshot, return immediately (no UI block)
|
||||
@@ -625,7 +369,7 @@ RealtimeRecordBackend::begin(const CaptureRequest& request,
|
||||
RealtimeCaptureHandle st(new RealtimeCaptureState());
|
||||
st->proj_ = proj;
|
||||
st->request_ = request;
|
||||
st->uniqueTag_ = makeUniqueTag();
|
||||
st->uniqueTag_ = makeUniqueTag("rt-"); // shared mint (T1-11 monotonic counter)
|
||||
st->paths_ = deriveBankPaths(projectDir, request.baseName, st->uniqueTag_);
|
||||
|
||||
// The recorded window end: extended past the range end for a tail mode (Auto/Manual),
|
||||
@@ -788,7 +532,9 @@ RealtimeTickResult RealtimeRecordBackend::tick(RealtimeCaptureState& state) {
|
||||
// ALL snapshotted state — the non-destructive gate, idempotent + unconditional.
|
||||
CaptureResult res;
|
||||
if (state.phase_ == RecordPhase::Done) {
|
||||
res = finalizeRecording(state);
|
||||
res = finalizeRecording(state.proj_, state.temp_, state.request_,
|
||||
state.paths_, state.uniqueTag_,
|
||||
state.recordWindowEnd_);
|
||||
} else {
|
||||
res.status = CaptureStatus::RenderFailed;
|
||||
res.message = "Realtime record timed out waiting for the recorded file to "
|
||||
@@ -844,7 +590,9 @@ RealtimeTickResult RealtimeRecordBackend::abort(RealtimeCaptureState& state) {
|
||||
// is the one that must be flush-safe.
|
||||
state.stopOwnTransport();
|
||||
|
||||
CaptureResult res = finalizeRecording(state);
|
||||
CaptureResult res = finalizeRecording(state.proj_, state.temp_, state.request_,
|
||||
state.paths_, state.uniqueTag_,
|
||||
state.recordWindowEnd_);
|
||||
state.markFinalized();
|
||||
state.restore(); // the non-destructive gate — always runs
|
||||
|
||||
@@ -855,4 +603,4 @@ RealtimeTickResult RealtimeRecordBackend::abort(RealtimeCaptureState& state) {
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace reasampler
|
||||
} // namespace reasampler::capture
|
||||
@@ -0,0 +1,101 @@
|
||||
// realtime_lifecycle.cpp — the in-flight realtime-capture state machine + globals
|
||||
// (Q-W3 hoist out of main.cpp; the code moved verbatim, the session threaded as a
|
||||
// parameter). See the header.
|
||||
//
|
||||
// Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.h
|
||||
// WITHOUT REAPERAPI_IMPLEMENT — main.cpp is the one TU that defines the API
|
||||
// pointers; here they are extern (CLAUDE.md §contract).
|
||||
|
||||
#include "shell/capture/realtime_lifecycle.h"
|
||||
|
||||
#include "persist.h" // ReaSamplerSession
|
||||
|
||||
#define REAPERAPI_MINIMAL
|
||||
#define REAPERAPI_WANT_EnumProjects
|
||||
#define REAPERAPI_WANT_ShowConsoleMsg
|
||||
#include "reaper_plugin_functions.h"
|
||||
|
||||
namespace reasampler::capture {
|
||||
|
||||
// --- M8 in-flight realtime capture (async, timer-driven) --------------------
|
||||
RealtimeRecordBackend g_rtBackend;
|
||||
RealtimeCaptureHandle g_rtCapture;
|
||||
ReaProject* g_rtCaptureProject = nullptr;
|
||||
|
||||
// Commit a finished realtime capture (a Done tick/abort with an Ok result): add the
|
||||
// Sample to the ACTIVE bank (session.bank() resolves to book.activeIndex() — B2),
|
||||
// persist + MarkProjectDirty. Shared by the tick-completion path and the abort
|
||||
// paths. On a non-Ok result, logs the failure only.
|
||||
void CommitRealtimeResult(ReaSamplerSession& session, const CaptureResult& res)
|
||||
{
|
||||
if (res.status != CaptureStatus::Ok)
|
||||
{
|
||||
ShowConsoleMsg(("ReaSampler realtime capture failed: " + res.message + "\n").c_str());
|
||||
return;
|
||||
}
|
||||
session.bank().add(res.sample);
|
||||
// B-cap: record the file the capture created in the owned-file manifest, at the same
|
||||
// point the Sample is added and before the same persist. Recorded regardless of the
|
||||
// index AddResult — even a hash-collapse still WROTE a file the tool owns, and the
|
||||
// manifest dedups a repeat path itself (Phase R prune reconciles manifest vs index).
|
||||
session.owned().add(res.sample.relativePath);
|
||||
// S9: a capture add changes what a live instance could play (a new sample landed in the
|
||||
// active bank) -> bump before the persist so the stamped generation refreshes instances.
|
||||
session.bumpBankGeneration();
|
||||
session.saveToActiveProject(); // persist book + manifest + generation + MarkProjectDirty (travels with .rpp)
|
||||
}
|
||||
|
||||
// Advance any in-flight realtime capture one tick. Cheap when none is running (a
|
||||
// null check) and fast even mid-record (tick() only reads the transport until the
|
||||
// terminal tick). Detects a project switch mid-capture and aborts+restores so the
|
||||
// capture never leaks across projects. Called from OnTimer BEFORE session.poll() so
|
||||
// poll's project-switch handling sees a cleaned-up project.
|
||||
void DriveRealtimeCapture(ReaSamplerSession& session)
|
||||
{
|
||||
if (!g_rtCapture) return;
|
||||
|
||||
// Project switch guard: if the active project is no longer the one the capture
|
||||
// belongs to, a new/other project became active mid-record — abort + restore
|
||||
// (into the ORIGINAL project the state is bound to) and drop it. Do NOT finalize
|
||||
// into the new project.
|
||||
ReaProject* active = EnumProjects(-1, nullptr, 0);
|
||||
if (active != g_rtCaptureProject)
|
||||
{
|
||||
RealtimeTickResult r = g_rtBackend.abort(*g_rtCapture);
|
||||
// Only commit if the ORIGINAL project is still open and active would be it —
|
||||
// on a switch we restored into the original but must not persist into the
|
||||
// now-active foreign project. Log the outcome without persisting. On a Failed
|
||||
// abort surface abort()'s own message — it distinguishes a clean tab-switch
|
||||
// abort from the closed-project DROP (the captured project was closed mid-record,
|
||||
// review §1: nothing restored because the pointers were already freed).
|
||||
if (r.status == RealtimeTickStatus::Done)
|
||||
ShowConsoleMsg("ReaSampler realtime capture: project switched mid-record -- "
|
||||
"captured audio restored into the original project; not "
|
||||
"persisted to avoid crossing projects.\n");
|
||||
else
|
||||
ShowConsoleMsg(("ReaSampler realtime capture: project changed mid-record -- " +
|
||||
r.result.message + "\n").c_str());
|
||||
g_rtCapture.reset();
|
||||
g_rtCaptureProject = nullptr;
|
||||
return;
|
||||
}
|
||||
|
||||
RealtimeTickResult r = g_rtBackend.tick(*g_rtCapture);
|
||||
if (r.status == RealtimeTickStatus::InProgress) return;
|
||||
|
||||
// Terminal (Done or Failed): commit/log and drop the in-flight state.
|
||||
CommitRealtimeResult(session, r.result);
|
||||
g_rtCapture.reset();
|
||||
g_rtCaptureProject = nullptr;
|
||||
}
|
||||
|
||||
void AbortRealtimeCaptureForUnload(ReaSamplerSession& session)
|
||||
{
|
||||
if (!g_rtCapture) return;
|
||||
RealtimeTickResult r = g_rtBackend.abort(*g_rtCapture);
|
||||
CommitRealtimeResult(session, r.result);
|
||||
g_rtCapture.reset();
|
||||
g_rtCaptureProject = nullptr;
|
||||
}
|
||||
|
||||
} // namespace reasampler::capture
|
||||
@@ -0,0 +1,56 @@
|
||||
#pragma once
|
||||
// realtime_lifecycle — the in-flight realtime-capture state machine + globals
|
||||
// (Q-W3 hoist out of main.cpp). A realtime record spans many timer ticks (it takes
|
||||
// end-start wall-clock seconds and must NOT block REAPER's UI): the action STARTS
|
||||
// it (capture_orchestrator::RunCaptureRealtimeTrack -> g_rtBackend.begin), OnTimer
|
||||
// drives it here (DriveRealtimeCapture -> g_rtBackend.tick) each tick until a
|
||||
// terminal verdict, then the handle is cleared.
|
||||
//
|
||||
// The three globals are EXPOSED (extern) rather than wrapped: the action bodies in
|
||||
// capture_orchestrator manipulate them exactly as main.cpp did (zero-behavior-change
|
||||
// move), and — load-bearing (CONTEXT.md §Phase Q hot-path guardrail) — the timer's
|
||||
// IDLE FAST-PATH stays a SINGLE POINTER TEST at the call site:
|
||||
// if (capture::g_rtCapture) capture::DriveRealtimeCapture(g_session);
|
||||
// No per-tick cross-TU call, no accessor indirection, when nothing is recording.
|
||||
//
|
||||
// REAPER-facing: the .cpp includes reaper_plugin_functions.h WITHOUT
|
||||
// REAPERAPI_IMPLEMENT (main.cpp owns the API pointers — CLAUDE.md §contract).
|
||||
|
||||
#include "shell/capture/capture.h" // RealtimeRecordBackend / RealtimeCaptureHandle
|
||||
|
||||
namespace reasampler {
|
||||
class ReaSamplerSession;
|
||||
}
|
||||
|
||||
namespace reasampler::capture {
|
||||
|
||||
// The realtime backend + the in-flight capture handle. Non-null handle == a
|
||||
// capture is in progress (used to reject a second one, to drive the per-tick
|
||||
// advance, and to abort on project switch / unload).
|
||||
extern RealtimeRecordBackend g_rtBackend;
|
||||
extern RealtimeCaptureHandle g_rtCapture;
|
||||
|
||||
// The ReaProject* the in-flight capture belongs to (opaque, compare-only) — lets
|
||||
// OnTimer detect a project switch mid-capture and abort+restore rather than leak the
|
||||
// temp track/arm/transport into or across projects. Only meaningful when
|
||||
// g_rtCapture != nullptr.
|
||||
extern ReaProject* g_rtCaptureProject;
|
||||
|
||||
// Commit a finished realtime capture (a Done tick/abort with an Ok result): add the
|
||||
// Sample to the ACTIVE bank, record the owned file, bump the generation, persist +
|
||||
// MarkProjectDirty. On a non-Ok result, logs the failure only.
|
||||
void CommitRealtimeResult(ReaSamplerSession& session, const CaptureResult& res);
|
||||
|
||||
// Advance any in-flight realtime capture one tick. Cheap when none is running (a
|
||||
// null check — though the caller already guards, see the header note) and fast even
|
||||
// mid-record. Detects a project switch mid-capture and aborts+restores so the
|
||||
// capture never leaks across projects. Called from OnTimer BEFORE session.poll().
|
||||
void DriveRealtimeCapture(ReaSamplerSession& session);
|
||||
|
||||
// Unload teardown: abort any in-flight capture while the API pointers are still
|
||||
// live — finalize-or-abort + restore so we never leave a temp track, an armed
|
||||
// track, or an altered transport/cursor in the user's project on unload. Commits
|
||||
// whatever was captured (best effort) before tearing down. No-op when idle.
|
||||
void AbortRealtimeCaptureForUnload(ReaSamplerSession& session);
|
||||
|
||||
} // namespace reasampler::capture
|
||||
@@ -0,0 +1,242 @@
|
||||
// scope_resolve.cpp — scope/source resolution for the capture action family
|
||||
// (Q-W3 hoist out of main.cpp; the code moved verbatim, session state threaded as
|
||||
// parameters). See the header.
|
||||
//
|
||||
// Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.h
|
||||
// WITHOUT REAPERAPI_IMPLEMENT — main.cpp is the one TU that defines the API
|
||||
// pointers; here they are extern (CLAUDE.md §contract).
|
||||
|
||||
#include "shell/capture/scope_resolve.h"
|
||||
|
||||
#include <filesystem> // project-dir derivation for provenance parent resolution
|
||||
#include <utility>
|
||||
|
||||
#include "shell/capture/provenance_shell.h" // fxChainIdentity* / *SourceFiles / bankFileRefs
|
||||
#include "shell/capture/track_guid.h" // guidString
|
||||
|
||||
#define REAPERAPI_MINIMAL
|
||||
#define REAPERAPI_WANT_GetSet_LoopTimeRange
|
||||
#define REAPERAPI_WANT_CountTracks
|
||||
#define REAPERAPI_WANT_GetTrack
|
||||
#define REAPERAPI_WANT_GetSetMediaTrackInfo_String
|
||||
#define REAPERAPI_WANT_EnumProjects
|
||||
#define REAPERAPI_WANT_CountSelectedMediaItems
|
||||
#define REAPERAPI_WANT_GetSelectedMediaItem
|
||||
#define REAPERAPI_WANT_GetMediaItem_Track
|
||||
#define REAPERAPI_WANT_CountSelectedTracks
|
||||
#define REAPERAPI_WANT_GetSelectedTrack
|
||||
#include "reaper_plugin_functions.h"
|
||||
|
||||
namespace reasampler::capture {
|
||||
|
||||
namespace {
|
||||
|
||||
// Time selection -> exact bounds (no rounding). GetSet_LoopTimeRange(isSet=false,
|
||||
// isLoop=false) reads the current time selection.
|
||||
bool resolveTimeSelection(double& start, double& end)
|
||||
{
|
||||
start = 0.0; end = 0.0;
|
||||
GetSet_LoopTimeRange(false, false, &start, &end, false);
|
||||
return end > start;
|
||||
}
|
||||
|
||||
// Maps a capture FX scope onto the pure provenance scope (kept decoupled so the
|
||||
// pure provenance module does not depend on render_settings).
|
||||
model::ProvenanceScope provenanceScopeFor(CaptureScope scope)
|
||||
{
|
||||
return scope == CaptureScope::Item ? model::ProvenanceScope::Item
|
||||
: model::ProvenanceScope::Track;
|
||||
}
|
||||
|
||||
// Collects the tracks that own the selected items (Item scope) into
|
||||
// out.sourceTracks (deduped) — these are the tracks whose FX must be bypassed so an
|
||||
// item capture hears take/item FX only. GetMediaItem_Track(item) gives the owning
|
||||
// track (SDK header, verify). GUIDs recorded for provenance.
|
||||
bool collectSelectedItemTracks(ResolvedSource& out)
|
||||
{
|
||||
const int n = CountSelectedMediaItems(nullptr);
|
||||
if (n <= 0) return false;
|
||||
for (int i = 0; i < n; ++i)
|
||||
{
|
||||
MediaItem* it = GetSelectedMediaItem(nullptr, i);
|
||||
if (!it) continue;
|
||||
MediaTrack* tr = GetMediaItem_Track(it);
|
||||
if (!tr) continue;
|
||||
// Dedup: several selected items can share a track.
|
||||
bool seen = false;
|
||||
for (MediaTrack* t : out.sourceTracks) if (t == tr) { seen = true; break; }
|
||||
if (seen) continue;
|
||||
out.sourceTracks.push_back(tr);
|
||||
std::string g = guidString(tr);
|
||||
if (!g.empty()) out.trackGuids.push_back(std::move(g));
|
||||
}
|
||||
return !out.sourceTracks.empty();
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
// Reads every track's P_RAZOREDITS (SDK header ~2899: space-separated triples of
|
||||
// start, end, envGuidString), parses the track-audio areas (pure parseRazorEdits),
|
||||
// and returns the union bound. Reads only — never clears the razor selection.
|
||||
// Returns false when no track-audio razor area exists on any track.
|
||||
bool resolveRazorRange(double& start, double& end)
|
||||
{
|
||||
std::vector<RazorRange> allRanges;
|
||||
const int n = CountTracks(nullptr);
|
||||
for (int i = 0; i < n; ++i)
|
||||
{
|
||||
MediaTrack* tr = GetTrack(nullptr, i);
|
||||
if (!tr) continue;
|
||||
std::vector<char> buf(8192, '\0');
|
||||
if (!GetSetMediaTrackInfo_String(tr, "P_RAZOREDITS", buf.data(), false))
|
||||
continue;
|
||||
std::vector<RazorRange> ranges = parseRazorEdits(std::string(buf.data()));
|
||||
for (auto& r : ranges) allRanges.push_back(r);
|
||||
}
|
||||
if (allRanges.empty()) return false;
|
||||
RazorRange u = razorUnionBounds(allRanges);
|
||||
start = u.startSeconds;
|
||||
end = u.endSeconds;
|
||||
return end > start;
|
||||
}
|
||||
|
||||
// Infers the render RANGE for any scope: razor union when a razor area is present,
|
||||
// else the time selection (pure inferRangeSource decides which). Orthogonal to
|
||||
// scope. Returns false (with a reason) when neither yields a non-empty range.
|
||||
bool resolveRange(double& start, double& end, std::string& why)
|
||||
{
|
||||
double rzStart = 0.0, rzEnd = 0.0;
|
||||
const bool hasRazor = resolveRazorRange(rzStart, rzEnd);
|
||||
if (inferRangeSource(hasRazor) == RangeSource::Razor)
|
||||
{
|
||||
start = rzStart; end = rzEnd;
|
||||
return true; // resolveRazorRange already verified end > start
|
||||
}
|
||||
if (resolveTimeSelection(start, end)) return true;
|
||||
why = "make a razor area or a time selection first";
|
||||
return false;
|
||||
}
|
||||
|
||||
// Collects the selected tracks (Track scope) into out.sourceTracks + GUIDs.
|
||||
bool collectSelectedTracks(ResolvedSource& out)
|
||||
{
|
||||
const int n = CountSelectedTracks(nullptr); // nullptr = active project
|
||||
if (n <= 0) return false;
|
||||
for (int i = 0; i < n; ++i)
|
||||
{
|
||||
MediaTrack* tr = GetSelectedTrack(nullptr, i);
|
||||
if (!tr) continue;
|
||||
out.sourceTracks.push_back(tr);
|
||||
std::string g = guidString(tr);
|
||||
if (!g.empty()) out.trackGuids.push_back(std::move(g));
|
||||
}
|
||||
return !out.sourceTracks.empty();
|
||||
}
|
||||
|
||||
// Resolves the source for a scope: the selection tracks (item/track), plus the
|
||||
// inferred range. Returns false with a reason on nothing to do.
|
||||
bool ResolveScopeSource(CaptureScope scope, ResolvedSource& out, std::string& why)
|
||||
{
|
||||
switch (scope)
|
||||
{
|
||||
case CaptureScope::Item:
|
||||
if (!collectSelectedItemTracks(out)) {
|
||||
why = "select at least one media item"; return false;
|
||||
}
|
||||
break;
|
||||
case CaptureScope::Track:
|
||||
if (!collectSelectedTracks(out)) {
|
||||
why = "select at least one track"; return false;
|
||||
}
|
||||
break;
|
||||
}
|
||||
return resolveRange(out.startSeconds, out.endSeconds, why);
|
||||
}
|
||||
|
||||
// Current project's directory (parent of its .rpp), forward-slashed, no trailing
|
||||
// slash — the same derivation capture.cpp does internally, needed here so M10 can
|
||||
// resolve the bank's relative paths to absolute for parent detection. Empty for an
|
||||
// unsaved project (EnumProjects writes an empty .rpp path), which makes every bank
|
||||
// file resolve empty -> no false parentage. Read-only; mutates nothing.
|
||||
std::string currentProjectDir()
|
||||
{
|
||||
std::vector<char> buf(4096, '\0');
|
||||
EnumProjects(-1, buf.data(), static_cast<int>(buf.size()));
|
||||
const std::string rpp(buf.data());
|
||||
if (rpp.empty()) return {};
|
||||
namespace fs = std::filesystem;
|
||||
std::string dir = fs::path(rpp).parent_path().string();
|
||||
for (char& c : dir) if (c == '\\') c = '/';
|
||||
if (dir.size() > 1 && dir.back() == '/') dir.pop_back();
|
||||
return dir;
|
||||
}
|
||||
|
||||
// Builds the M10 provenance for a capture IF it genuinely resamples from a bank
|
||||
// sample, else returns nullopt (the common, non-resample case). Detection rule
|
||||
// (stated honestly): the capture's source item media file(s) must all resolve, by
|
||||
// exact normalized absolute path, to ONE bank sample's file (detectParent). On a
|
||||
// match, records that sample's id as the parent plus a THIN capture-recipe
|
||||
// fingerprint (P1=a) — scope + source mode + exact range + tail + rate + channels +
|
||||
// source track GUIDs + the in-scope source FX-chain identity — so "re-capture from
|
||||
// source" can replay the request and report drift. NEVER a serialized chain to
|
||||
// restore. Item scope reads the active take's TakeFX chain (via TakeFX_*) per
|
||||
// selected item, combined in item order; Track scope reads the track FX chain.
|
||||
std::optional<model::Provenance> buildCaptureProvenance(
|
||||
const BankBook& book, const CaptureRequest& req,
|
||||
CaptureScope scope, const ResolvedSource& src)
|
||||
{
|
||||
const std::string projectDir = currentProjectDir();
|
||||
const std::vector<model::BankFileRef> bankFiles = bankFileRefs(book, projectDir);
|
||||
|
||||
// The "what audio is being captured" source set depends on scope: item scope uses
|
||||
// the SELECTED items (the user picked them); track scope uses the range-overlapping
|
||||
// items ON the source tracks (the user picked the track, not the item).
|
||||
const std::vector<std::string> sourceFiles =
|
||||
scope == CaptureScope::Item
|
||||
? selectedItemSourceFiles()
|
||||
: trackItemSourceFiles(src.sourceTracks, req.startSeconds,
|
||||
req.endSeconds);
|
||||
|
||||
const std::optional<std::string> parentId =
|
||||
model::detectParent(sourceFiles, bankFiles);
|
||||
if (!parentId) return std::nullopt; // not a resample-from-sample — no provenance
|
||||
|
||||
model::CaptureRecipe recipe;
|
||||
recipe.scope = provenanceScopeFor(scope);
|
||||
recipe.sourceMode = static_cast<int>(req.sourceMode);
|
||||
recipe.startSeconds = req.startSeconds;
|
||||
recipe.endSeconds = req.endSeconds;
|
||||
recipe.tailMode = static_cast<int>(req.tailMode);
|
||||
recipe.tailMs = req.tailMs;
|
||||
recipe.sampleRate = req.sampleRate;
|
||||
recipe.channelCount = req.channelCount;
|
||||
recipe.trackGuids = req.trackGuids;
|
||||
// The in-scope FX-chain identity:
|
||||
// Track scope — per-track chains combined in track order (TrackFX_*).
|
||||
// Item scope — per-item active-take chains combined in item order (TakeFX_*);
|
||||
// the owning track's FX chain is OUT OF SCOPE for an item capture and must
|
||||
// not be fingerprinted here (it is bypassed during render, not heard).
|
||||
if (scope == CaptureScope::Item) {
|
||||
const int n = CountSelectedMediaItems(nullptr);
|
||||
std::vector<MediaItem*> items;
|
||||
items.reserve(static_cast<std::size_t>(n < 0 ? 0 : n));
|
||||
for (int i = 0; i < n; ++i) {
|
||||
MediaItem* it = GetSelectedMediaItem(nullptr, i);
|
||||
if (it) items.push_back(it);
|
||||
}
|
||||
recipe.fxChainIdentity = fxChainIdentityForItems(items);
|
||||
} else {
|
||||
std::vector<std::string> perTrack;
|
||||
perTrack.reserve(src.sourceTracks.size());
|
||||
for (MediaTrack* tr : src.sourceTracks)
|
||||
perTrack.push_back(fxChainIdentityForTrack(tr));
|
||||
recipe.fxChainIdentity = model::combineChainIdentities(perTrack);
|
||||
}
|
||||
|
||||
model::Provenance prov;
|
||||
prov.parentSampleId = *parentId;
|
||||
prov.fxChainSnapshot = model::buildFingerprint(recipe);
|
||||
return prov;
|
||||
}
|
||||
|
||||
} // namespace reasampler::capture
|
||||
@@ -0,0 +1,71 @@
|
||||
#pragma once
|
||||
// scope_resolve — scope/source resolution for the capture action family (Q-W3
|
||||
// hoist out of main.cpp). The three concerns every capture entry point shares:
|
||||
// * RANGE inference — razor union else time selection (razor-else-time),
|
||||
// orthogonal to scope;
|
||||
// * SOURCE-TRACK collection — the selected tracks (Track scope) or the selected
|
||||
// items' owning tracks (Item scope), deduped, with canonical GUIDs;
|
||||
// * PROVENANCE ASSEMBLY inputs — the M10 resample-from-sample detection + the
|
||||
// thin capture-recipe fingerprint built from the LIVE (un-bypassed) chain.
|
||||
//
|
||||
// All reads are non-destructive: selection, razor, and time selection are read,
|
||||
// never mutated. REAPER-facing: the .cpp includes reaper_plugin_functions.h
|
||||
// WITHOUT REAPERAPI_IMPLEMENT (main.cpp owns the API pointers — CLAUDE.md
|
||||
// §contract). MediaTrack is forward-declared (via capture.h) so this header stays
|
||||
// SDK-lite.
|
||||
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "shell/capture/capture.h" // CaptureRequest, MediaTrack fwd
|
||||
#include "core/capture/render_settings.h" // CaptureScope
|
||||
#include "core/model/provenance.h" // model::Provenance
|
||||
|
||||
namespace reasampler {
|
||||
class BankBook;
|
||||
}
|
||||
|
||||
namespace reasampler::capture {
|
||||
|
||||
// The resolved source: exact bounds + the source tracks (for FX-bypass + Sample
|
||||
// provenance GUIDs). `sourceTracks` holds the item-owning tracks (Item scope) or the
|
||||
// selected tracks (Track scope).
|
||||
struct ResolvedSource
|
||||
{
|
||||
double startSeconds = 0.0;
|
||||
double endSeconds = 0.0;
|
||||
std::vector<MediaTrack*> sourceTracks; // item-owning tracks / selected tracks
|
||||
std::vector<std::string> trackGuids; // canonical GUIDs of sourceTracks
|
||||
};
|
||||
|
||||
// Reads every track's P_RAZOREDITS, parses the track-audio areas (pure
|
||||
// parseRazorEdits), and returns the union bound. Reads only — never clears the
|
||||
// razor selection. Returns false when no track-audio razor area exists on any track.
|
||||
bool resolveRazorRange(double& start, double& end);
|
||||
|
||||
// Infers the render RANGE for any scope: razor union when a razor area is present,
|
||||
// else the time selection (pure inferRangeSource decides which). Orthogonal to
|
||||
// scope. Returns false (with a reason) when neither yields a non-empty range.
|
||||
bool resolveRange(double& start, double& end, std::string& why);
|
||||
|
||||
// Collects the selected tracks (Track scope) into out.sourceTracks + GUIDs.
|
||||
bool collectSelectedTracks(ResolvedSource& out);
|
||||
|
||||
// Resolves the source for a scope: the selection tracks (item/track), plus the
|
||||
// inferred range. Returns false with a reason on nothing to do.
|
||||
bool ResolveScopeSource(CaptureScope scope, ResolvedSource& out, std::string& why);
|
||||
|
||||
// Current project's directory (parent of its .rpp), forward-slashed, no trailing
|
||||
// slash. Empty for an unsaved project (no false parentage). Read-only.
|
||||
std::string currentProjectDir();
|
||||
|
||||
// Builds the M10 provenance for a capture IF it genuinely resamples from a bank
|
||||
// sample (detectParent over `book`'s resolved file refs), else returns nullopt (the
|
||||
// common, non-resample case). Must run BEFORE the FxBypassGuard neutralizes the
|
||||
// in-scope chain — the source FX-chain identity is read from the LIVE chain.
|
||||
std::optional<model::Provenance> buildCaptureProvenance(
|
||||
const BankBook& book, const CaptureRequest& req,
|
||||
CaptureScope scope, const ResolvedSource& src);
|
||||
|
||||
} // namespace reasampler::capture
|
||||
@@ -5,11 +5,8 @@
|
||||
|
||||
#include "../src/core/capture/capture_paths.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <cstring> // std::memcpy (for putF32cp in hashWavContent tests)
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
using namespace reasampler;
|
||||
using namespace reasampler::capture;
|
||||
@@ -345,208 +342,9 @@ static void testTransitionInPlaceSaveIsNoOp() {
|
||||
== ProjectTransition::NoOp);
|
||||
}
|
||||
|
||||
// --- hashBytes (FNV-1a content hash) ----------------------------------------
|
||||
//
|
||||
// The fix for the confirm-on-last-reference bug: hashBytes produces a 16-char hex
|
||||
// string that capture.cpp and capture_realtime.cpp store on Sample::contentHash so
|
||||
// BankBook::hashReferencedElsewhere can detect copies and suppress the confirm when
|
||||
// another bank still holds the same file.
|
||||
|
||||
static void testHashBytesOutputFormat() {
|
||||
// Output is always 16 lowercase hex characters.
|
||||
const std::uint8_t bytes[] = {0x01, 0x02, 0x03};
|
||||
const std::string h = hashBytes(bytes, 3);
|
||||
CHECK(h.size() == 16);
|
||||
for (char c : h) {
|
||||
CHECK((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f'));
|
||||
}
|
||||
}
|
||||
|
||||
static void testHashBytesDeterministic() {
|
||||
// Same input always produces the same output (bit-identical captures get
|
||||
// the same hash, so hashReferencedElsewhere fires correctly for copies).
|
||||
const std::uint8_t bytes[] = {0xDE, 0xAD, 0xBE, 0xEF, 0x01};
|
||||
CHECK(hashBytes(bytes, 5) == hashBytes(bytes, 5));
|
||||
}
|
||||
|
||||
static void testHashBytesDistinct() {
|
||||
// Different inputs produce different hashes (no accidental dedup of distinct
|
||||
// files). This covers the "one-bit-flip changes the hash" property.
|
||||
std::uint8_t a[] = {0x00, 0x00};
|
||||
std::uint8_t b[] = {0x00, 0x01};
|
||||
CHECK(hashBytes(a, 2) != hashBytes(b, 2));
|
||||
|
||||
std::uint8_t c[] = {0xFF, 0xFF, 0xFF};
|
||||
std::uint8_t d[] = {0xFF, 0xFF, 0xFE};
|
||||
CHECK(hashBytes(c, 3) != hashBytes(d, 3));
|
||||
}
|
||||
|
||||
static void testHashBytesEmptyBufferIsNonEmpty() {
|
||||
// An empty buffer returns the FNV-1a offset basis in hex (stable, non-empty
|
||||
// sentinel) — capturing the contract that even empty inputs yield a 16-char hash.
|
||||
const std::string h = hashBytes(nullptr, 0);
|
||||
CHECK(h.size() == 16);
|
||||
}
|
||||
|
||||
static void testHashBytesLargerBufferDiffersFromSmaller() {
|
||||
// Padding a buffer with a zero byte must change the hash (order + length
|
||||
// sensitivity so two differently-sized WAV files don't accidentally collide).
|
||||
const std::uint8_t short_buf[] = {0xAB, 0xCD};
|
||||
const std::uint8_t long_buf[] = {0xAB, 0xCD, 0x00};
|
||||
CHECK(hashBytes(short_buf, 2) != hashBytes(long_buf, 3));
|
||||
}
|
||||
|
||||
// --- hashWavContent (WAV-aware dedup hash) -----------------------------------
|
||||
//
|
||||
// Verifies that the WAV-content hash hashes only fmt+data (skipping metadata
|
||||
// chunks like bext/LIST), falls back gracefully for non-WAV input, and that
|
||||
// different audio data yields different hashes.
|
||||
|
||||
// Minimal synthetic WAV builder (mirrors the one in test_wav_trim.cpp).
|
||||
static void putU16cp(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 putU32cp(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 putTagcp(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 putF32cp(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]);
|
||||
}
|
||||
|
||||
// Builds a minimal 32-bit-float RIFF/WAVE with an optional metadata chunk
|
||||
// inserted between "WAVE" and the fmt chunk. `metaChunkBody` and `metaTag` are
|
||||
// used when `insertMeta` is true. This is the shape REAPER produces: a `bext`
|
||||
// or `LIST` chunk before fmt with a render-time timestamp in the body.
|
||||
static std::vector<std::uint8_t> buildTestWav(
|
||||
std::uint16_t channels, std::uint32_t sampleRate,
|
||||
const std::vector<float>& samples,
|
||||
bool insertMeta = false,
|
||||
const char* metaTag = "bext",
|
||||
const std::vector<std::uint8_t>& metaBody = {}) {
|
||||
|
||||
std::vector<std::uint8_t> chunks;
|
||||
|
||||
if (insertMeta && !metaBody.empty()) {
|
||||
putTagcp(chunks, metaTag);
|
||||
putU32cp(chunks, static_cast<std::uint32_t>(metaBody.size()));
|
||||
chunks.insert(chunks.end(), metaBody.begin(), metaBody.end());
|
||||
if (metaBody.size() & 1u) chunks.push_back(0); // RIFF pad
|
||||
}
|
||||
|
||||
// fmt chunk (16-byte body, IEEE-float tag 3).
|
||||
const std::uint32_t dataBytes =
|
||||
static_cast<std::uint32_t>(samples.size() * 4u);
|
||||
putTagcp(chunks, "fmt ");
|
||||
putU32cp(chunks, 16);
|
||||
putU16cp(chunks, 3); // IEEE float
|
||||
putU16cp(chunks, channels);
|
||||
putU32cp(chunks, sampleRate);
|
||||
putU32cp(chunks, sampleRate * channels * 4u); // byteRate
|
||||
putU16cp(chunks, static_cast<std::uint16_t>(channels * 4)); // blockAlign
|
||||
putU16cp(chunks, 32); // bitsPerSample
|
||||
|
||||
// data chunk.
|
||||
putTagcp(chunks, "data");
|
||||
putU32cp(chunks, dataBytes);
|
||||
for (float f : samples) putF32cp(chunks, f);
|
||||
|
||||
std::vector<std::uint8_t> wav;
|
||||
putTagcp(wav, "RIFF");
|
||||
putU32cp(wav, static_cast<std::uint32_t>(4 + chunks.size()));
|
||||
putTagcp(wav, "WAVE");
|
||||
wav.insert(wav.end(), chunks.begin(), chunks.end());
|
||||
return wav;
|
||||
}
|
||||
|
||||
static void testHashWavContentIdenticalAudioSameHash() {
|
||||
// Two WAVs with the same audio but different metadata body -> same hash.
|
||||
// This is the core dedup regression: REAPER embeds a bext chunk with a
|
||||
// render-time origination timestamp; without WAV-aware hashing, two renders
|
||||
// of the same clip produce different file bytes -> no dedup collapse.
|
||||
const std::vector<float> audio = {0.1f, -0.2f, 0.3f, -0.4f};
|
||||
std::vector<std::uint8_t> metaA(64, 0x00); // bext body, all zeros (e.g. epoch)
|
||||
std::vector<std::uint8_t> metaB(64, 0x00);
|
||||
// Different origination timestamps: first 10 bytes of bext are ASCII date/time.
|
||||
metaB[0] = '2'; metaB[1] = '0'; metaB[2] = '2'; metaB[3] = '6'; // year
|
||||
|
||||
auto wavA = buildTestWav(1, 44100, audio, /*meta=*/true, "bext", metaA);
|
||||
auto wavB = buildTestWav(1, 44100, audio, /*meta=*/true, "bext", metaB);
|
||||
|
||||
// Files must differ (the bext body is different) to prove the test is valid.
|
||||
CHECK(wavA != wavB);
|
||||
|
||||
// But their content hashes must be equal: same fmt+data, different metadata.
|
||||
CHECK(hashWavContent(wavA) == hashWavContent(wavB));
|
||||
}
|
||||
|
||||
static void testHashWavContentDifferentAudioDifferentHash() {
|
||||
// Different PCM data -> different content hashes (no false dedup).
|
||||
const std::vector<float> audioA = {0.5f, 0.5f};
|
||||
const std::vector<float> audioB = {0.5f, 0.6f}; // last sample differs
|
||||
|
||||
auto wavA = buildTestWav(1, 44100, audioA);
|
||||
auto wavB = buildTestWav(1, 44100, audioB);
|
||||
|
||||
CHECK(hashWavContent(wavA) != hashWavContent(wavB));
|
||||
}
|
||||
|
||||
static void testHashWavContentDifferentFmtDifferentHash() {
|
||||
// Different fmt fields (sample rate) -> different content hashes.
|
||||
const std::vector<float> audio = {0.1f, 0.2f};
|
||||
auto wav44 = buildTestWav(1, 44100, audio);
|
||||
auto wav48 = buildTestWav(1, 48000, audio);
|
||||
CHECK(hashWavContent(wav44) != hashWavContent(wav48));
|
||||
}
|
||||
|
||||
static void testHashWavContentNonWavFallsBackToWholeFile() {
|
||||
// Non-WAV bytes -> falls back to whole-file hashBytes; result is non-empty
|
||||
// and equals hashBytes of the same bytes directly.
|
||||
std::vector<std::uint8_t> notWav = {0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x02};
|
||||
const std::string h = hashWavContent(notWav);
|
||||
CHECK(!h.empty());
|
||||
CHECK(h.size() == 16);
|
||||
CHECK(h == hashBytes(notWav.data(), notWav.size()));
|
||||
}
|
||||
|
||||
static void testHashWavContentEmptyFallsBackToHashBytes() {
|
||||
// Empty vector -> falls back to whole-file hashBytes (the FNV offset basis).
|
||||
std::vector<std::uint8_t> empty;
|
||||
const std::string h = hashWavContent(empty);
|
||||
CHECK(!h.empty());
|
||||
CHECK(h.size() == 16);
|
||||
CHECK(h == hashBytes(nullptr, 0));
|
||||
}
|
||||
|
||||
static void testHashWavContentListMetaSkipped() {
|
||||
// A LIST/INFO chunk (another common metadata chunk) is likewise skipped.
|
||||
const std::vector<float> audio = {1.0f, -1.0f, 0.5f};
|
||||
std::vector<std::uint8_t> listBody = {'I','N','F','O', 'x','x','x','x'};
|
||||
auto wavClean = buildTestWav(1, 48000, audio);
|
||||
auto wavList = buildTestWav(1, 48000, audio, true, "LIST", listBody);
|
||||
|
||||
// Content hashes must match: only the LIST chunk differs.
|
||||
CHECK(hashWavContent(wavClean) == hashWavContent(wavList));
|
||||
}
|
||||
|
||||
static void testHashWavContentDomainSeparationFromWholeFile() {
|
||||
// The content hash ('W'-prefixed) must not accidentally equal the whole-file
|
||||
// hash of the SAME bytes. This guards against the domain-separation prefix
|
||||
// being dropped or zeroed out.
|
||||
const std::vector<float> audio = {0.0f};
|
||||
auto wav = buildTestWav(1, 44100, audio);
|
||||
const std::string contentHash = hashWavContent(wav);
|
||||
const std::string wholeHash = hashBytes(wav.data(), wav.size());
|
||||
CHECK(contentHash != wholeHash);
|
||||
}
|
||||
// NOTE (Q-W3, audit §4e): the hashBytes / hashWavContent tests moved to
|
||||
// tests/test_wav_codec.cpp with the implementations — capture_paths is now path
|
||||
// arithmetic only, with no content-hash / RIFF knowledge.
|
||||
|
||||
// --- bankRelativeForName spelling consistency (Phase R, R2) -----------------
|
||||
//
|
||||
@@ -597,18 +395,6 @@ int main() {
|
||||
testTransitionTwoUnsavedProjectsSwitchLoads();
|
||||
testTransitionFirstSaveOfUnsavedRelocatesButPlanNoOps();
|
||||
testTransitionInPlaceSaveIsNoOp();
|
||||
testHashBytesOutputFormat();
|
||||
testHashBytesDeterministic();
|
||||
testHashBytesDistinct();
|
||||
testHashBytesEmptyBufferIsNonEmpty();
|
||||
testHashBytesLargerBufferDiffersFromSmaller();
|
||||
testHashWavContentIdenticalAudioSameHash();
|
||||
testHashWavContentDifferentAudioDifferentHash();
|
||||
testHashWavContentDifferentFmtDifferentHash();
|
||||
testHashWavContentNonWavFallsBackToWholeFile();
|
||||
testHashWavContentEmptyFallsBackToHashBytes();
|
||||
testHashWavContentListMetaSkipped();
|
||||
testHashWavContentDomainSeparationFromWholeFile();
|
||||
testBankRelativeForNameMatchesDerivePathSpelling();
|
||||
testBankRelativeForNameConventionAndEdge();
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
// Standalone tests for reasampler::realtime_record — no REAPER, no framework.
|
||||
// Standalone tests for reasampler::capture_realtime (renamed from realtime_record
|
||||
// in Q-W3 — the Q-9 naming rider) — no REAPER, no framework.
|
||||
// Covers the two pure pieces behind the realtime-record backend (M8): the
|
||||
// record-mode/recipe bookkeeping (channel count + tap -> I_RECMODE / I_RECMODE_FLAGS)
|
||||
// and the wet/dry -> tap decision, plus the recorded-file -> Sample mapping.
|
||||
|
||||
#include "../src/core/capture/realtime_record.h"
|
||||
#include "../src/core/capture/capture_realtime.h"
|
||||
|
||||
#include <cstdio>
|
||||
#include <string>
|
||||
@@ -328,7 +329,7 @@ int main() {
|
||||
testStopRequestedClassification();
|
||||
testIsTerminalPhaseClassification();
|
||||
|
||||
if (g_fail == 0) std::printf("realtime_record: all tests passed\n");
|
||||
else std::printf("realtime_record: %d CHECK(s) FAILED\n", g_fail);
|
||||
if (g_fail == 0) std::printf("capture_realtime: all tests passed\n");
|
||||
else std::printf("capture_realtime: %d CHECK(s) FAILED\n", g_fail);
|
||||
return g_fail == 0 ? 0 : 1;
|
||||
}
|
||||
@@ -1,13 +1,17 @@
|
||||
// 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).
|
||||
// Standalone tests for reasampler::wav_codec — no REAPER, no test framework.
|
||||
// The ONE pure WAV/RIFF owner (Q-W3, audit §4e): builds synthetic 32-bit-float WAV
|
||||
// byte buffers, asserts the parse geometry, the float extraction, the truncate-plan
|
||||
// arithmetic + size-field patch, the float32 build round-trip, and the WAV-aware
|
||||
// content hashes (moved here from capture_paths with the hash implementations).
|
||||
//
|
||||
// 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.
|
||||
// no-op keep-all, kept==0, grow rejected) with exact size-field values; patchU32LE;
|
||||
// buildFloat32Wav golden header + parse round-trip; hashBytes/hashWavContent
|
||||
// determinism, metadata-skip, fallback, and domain separation.
|
||||
|
||||
#include "../src/core/capture/wav_trim.h"
|
||||
#include "../src/core/capture/wav_codec.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
@@ -263,15 +267,10 @@ static void testTruncatePlanKeepFewer() {
|
||||
// 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);
|
||||
// Patch the two size fields with the module's own patch primitive (what the
|
||||
// shell does before truncating on disk).
|
||||
patchU32LE(trimmed, p.dataSizeFieldOffset, p.newDataSize);
|
||||
patchU32LE(trimmed, p.riffSizeFieldOffset, p.newRiffSize);
|
||||
|
||||
WavLayout L2 = parseWavLayout(trimmed);
|
||||
CHECK(L2.valid);
|
||||
@@ -325,14 +324,8 @@ static void testExtensibleFloatAccepted() {
|
||||
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);
|
||||
patchU32LE(trimmed, p.dataSizeFieldOffset, p.newDataSize);
|
||||
patchU32LE(trimmed, p.riffSizeFieldOffset, p.newRiffSize);
|
||||
WavLayout L2 = parseWavLayout(trimmed);
|
||||
CHECK(L2.valid);
|
||||
CHECK(L2.frameCount() == 3);
|
||||
@@ -356,6 +349,233 @@ static void testTruncatePlanKeepZeroAndGrowRejected() {
|
||||
CHECK(!planWavTruncate(bad, 0).valid);
|
||||
}
|
||||
|
||||
// --- patchU32LE (the size-field patch primitive) ------------------------------
|
||||
|
||||
static void testPatchU32LEWritesLittleEndian() {
|
||||
std::vector<std::uint8_t> buf(8, 0xEE);
|
||||
patchU32LE(buf, 2, 0x0A0B0C0Du);
|
||||
CHECK(buf[0] == 0xEE && buf[1] == 0xEE); // bytes outside the field untouched
|
||||
CHECK(buf[2] == 0x0D && buf[3] == 0x0C && buf[4] == 0x0B && buf[5] == 0x0A);
|
||||
CHECK(buf[6] == 0xEE && buf[7] == 0xEE);
|
||||
}
|
||||
|
||||
// --- buildFloat32Wav (the one WAV writer, absorbed from ingest — T4-10) -------
|
||||
|
||||
static void testBuildFloat32WavGoldenHeaderAndRoundTrip() {
|
||||
// 2 channels, 3 frames of known interleaved values.
|
||||
const std::vector<double> pcm = {0.0, 0.5, -0.25, 1.0, -1.0, 0.125};
|
||||
auto wav = buildFloat32Wav(2, 48000, 3, pcm);
|
||||
|
||||
// Golden container shape: 44-byte header + 6 samples * 4 bytes.
|
||||
CHECK(wav.size() == 44u + 6u * 4u);
|
||||
CHECK(std::memcmp(wav.data(), "RIFF", 4) == 0);
|
||||
CHECK(std::memcmp(wav.data() + 8, "WAVE", 4) == 0);
|
||||
CHECK(std::memcmp(wav.data() + 12, "fmt ", 4) == 0);
|
||||
CHECK(std::memcmp(wav.data() + 36, "data", 4) == 0);
|
||||
CHECK(wav[20] == 0x03 && wav[21] == 0x00); // WAVE_FORMAT_IEEE_FLOAT
|
||||
CHECK(wav[34] == 32 && wav[35] == 0); // bitsPerSample = 32
|
||||
|
||||
// The build round-trips through the module's own parse + extraction, with the
|
||||
// documented double->float narrowing.
|
||||
WavLayout L = parseWavLayout(wav);
|
||||
CHECK(L.valid);
|
||||
CHECK(L.channelCount == 2);
|
||||
CHECK(L.sampleRate == 48000);
|
||||
CHECK(L.frameCount() == 3);
|
||||
auto back = extractFloatFrames(wav, L, 0, 3);
|
||||
CHECK(back.size() == 6);
|
||||
for (std::size_t i = 0; i < back.size(); ++i)
|
||||
CHECK(back[i] == static_cast<float>(pcm[i]));
|
||||
}
|
||||
|
||||
static void testBuildFloat32WavShortInputRejectedByParse() {
|
||||
// Fewer interleaved samples than frameCount*nch declares: the data chunk still
|
||||
// declares the full length; the missing tail simply is not written. The build
|
||||
// caller (ingest) always passes a full buffer; this locks the clamp-no-OOB shape.
|
||||
const std::vector<double> pcm = {1.0}; // 1 sample for a 2-frame mono request
|
||||
auto wav = buildFloat32Wav(1, 44100, 2, pcm);
|
||||
// Declared data size covers 2 frames; actual bytes stop after 1 sample, so the
|
||||
// declared length overruns the buffer -> parse rejects (the honest verdict for
|
||||
// a short-fed build; ingest never produces this).
|
||||
CHECK(wav.size() == 44u + 4u);
|
||||
CHECK(!parseWavLayout(wav).valid);
|
||||
}
|
||||
|
||||
// --- hashBytes (FNV-1a content hash — moved with the impl from capture_paths) --
|
||||
|
||||
static void testHashBytesOutputFormat() {
|
||||
// Output is always 16 lowercase hex characters.
|
||||
const std::uint8_t bytes[] = {0x01, 0x02, 0x03};
|
||||
const std::string h = hashBytes(bytes, 3);
|
||||
CHECK(h.size() == 16);
|
||||
for (char c : h) {
|
||||
CHECK((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f'));
|
||||
}
|
||||
}
|
||||
|
||||
static void testHashBytesDeterministicAndDistinct() {
|
||||
// Same input always produces the same output; different inputs differ (no
|
||||
// accidental dedup of distinct files), including a one-bit flip and a
|
||||
// trailing-zero-byte length change.
|
||||
const std::uint8_t bytes[] = {0xDE, 0xAD, 0xBE, 0xEF, 0x01};
|
||||
CHECK(hashBytes(bytes, 5) == hashBytes(bytes, 5));
|
||||
|
||||
std::uint8_t a[] = {0x00, 0x00};
|
||||
std::uint8_t b[] = {0x00, 0x01};
|
||||
CHECK(hashBytes(a, 2) != hashBytes(b, 2));
|
||||
|
||||
const std::uint8_t short_buf[] = {0xAB, 0xCD};
|
||||
const std::uint8_t long_buf[] = {0xAB, 0xCD, 0x00};
|
||||
CHECK(hashBytes(short_buf, 2) != hashBytes(long_buf, 3));
|
||||
}
|
||||
|
||||
static void testHashBytesEmptyBufferIsNonEmpty() {
|
||||
// An empty buffer returns the FNV-1a offset basis in hex (stable, non-empty
|
||||
// sentinel) — capturing the contract that even empty inputs yield a 16-char hash.
|
||||
const std::string h = hashBytes(nullptr, 0);
|
||||
CHECK(h.size() == 16);
|
||||
}
|
||||
|
||||
// --- hashWavContent (WAV-aware dedup hash) -----------------------------------
|
||||
//
|
||||
// Verifies that the WAV-content hash hashes only fmt+data (skipping metadata
|
||||
// chunks like bext/LIST), falls back gracefully for non-WAV input, and that
|
||||
// different audio data yields different hashes.
|
||||
|
||||
// Builds a minimal 32-bit-float RIFF/WAVE with an optional metadata chunk
|
||||
// inserted between "WAVE" and the fmt chunk. This is the shape REAPER produces: a
|
||||
// `bext` or `LIST` chunk before fmt with a render-time timestamp in the body.
|
||||
static std::vector<std::uint8_t> buildMetaWav(
|
||||
std::uint16_t channels, std::uint32_t sampleRate,
|
||||
const std::vector<float>& samples,
|
||||
bool insertMeta = false,
|
||||
const char* metaTag = "bext",
|
||||
const std::vector<std::uint8_t>& metaBody = {}) {
|
||||
|
||||
std::vector<std::uint8_t> chunks;
|
||||
|
||||
if (insertMeta && !metaBody.empty()) {
|
||||
putTag(chunks, metaTag);
|
||||
putU32(chunks, static_cast<std::uint32_t>(metaBody.size()));
|
||||
chunks.insert(chunks.end(), metaBody.begin(), metaBody.end());
|
||||
if (metaBody.size() & 1u) chunks.push_back(0); // RIFF pad
|
||||
}
|
||||
|
||||
// fmt chunk (16-byte body, IEEE-float tag 3).
|
||||
const std::uint32_t dataBytes =
|
||||
static_cast<std::uint32_t>(samples.size() * 4u);
|
||||
putTag(chunks, "fmt ");
|
||||
putU32(chunks, 16);
|
||||
putU16(chunks, 3); // IEEE float
|
||||
putU16(chunks, channels);
|
||||
putU32(chunks, sampleRate);
|
||||
putU32(chunks, sampleRate * channels * 4u); // byteRate
|
||||
putU16(chunks, static_cast<std::uint16_t>(channels * 4)); // blockAlign
|
||||
putU16(chunks, 32); // bitsPerSample
|
||||
|
||||
// data chunk.
|
||||
putTag(chunks, "data");
|
||||
putU32(chunks, dataBytes);
|
||||
for (float f : samples) putFloat(chunks, f);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
static void testHashWavContentIdenticalAudioSameHash() {
|
||||
// Two WAVs with the same audio but different metadata body -> same hash.
|
||||
// This is the core dedup regression: REAPER embeds a bext chunk with a
|
||||
// render-time origination timestamp; without WAV-aware hashing, two renders
|
||||
// of the same clip produce different file bytes -> no dedup collapse.
|
||||
const std::vector<float> audio = {0.1f, -0.2f, 0.3f, -0.4f};
|
||||
std::vector<std::uint8_t> metaA(64, 0x00); // bext body, all zeros (e.g. epoch)
|
||||
std::vector<std::uint8_t> metaB(64, 0x00);
|
||||
// Different origination timestamps: first 10 bytes of bext are ASCII date/time.
|
||||
metaB[0] = '2'; metaB[1] = '0'; metaB[2] = '2'; metaB[3] = '6'; // year
|
||||
|
||||
auto wavA = buildMetaWav(1, 44100, audio, /*meta=*/true, "bext", metaA);
|
||||
auto wavB = buildMetaWav(1, 44100, audio, /*meta=*/true, "bext", metaB);
|
||||
|
||||
// Files must differ (the bext body is different) to prove the test is valid.
|
||||
CHECK(wavA != wavB);
|
||||
|
||||
// But their content hashes must be equal: same fmt+data, different metadata.
|
||||
CHECK(hashWavContent(wavA) == hashWavContent(wavB));
|
||||
}
|
||||
|
||||
static void testHashWavContentDifferentAudioDifferentHash() {
|
||||
// Different PCM data -> different content hashes (no false dedup).
|
||||
const std::vector<float> audioA = {0.5f, 0.5f};
|
||||
const std::vector<float> audioB = {0.5f, 0.6f}; // last sample differs
|
||||
|
||||
auto wavA = buildMetaWav(1, 44100, audioA);
|
||||
auto wavB = buildMetaWav(1, 44100, audioB);
|
||||
|
||||
CHECK(hashWavContent(wavA) != hashWavContent(wavB));
|
||||
}
|
||||
|
||||
static void testHashWavContentDifferentFmtDifferentHash() {
|
||||
// Different fmt fields (sample rate) -> different content hashes.
|
||||
const std::vector<float> audio = {0.1f, 0.2f};
|
||||
auto wav44 = buildMetaWav(1, 44100, audio);
|
||||
auto wav48 = buildMetaWav(1, 48000, audio);
|
||||
CHECK(hashWavContent(wav44) != hashWavContent(wav48));
|
||||
}
|
||||
|
||||
static void testHashWavContentNonWavFallsBackToWholeFile() {
|
||||
// Non-WAV bytes -> falls back to whole-file hashBytes; result is non-empty
|
||||
// and equals hashBytes of the same bytes directly. Empty input likewise.
|
||||
std::vector<std::uint8_t> notWav = {0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x02};
|
||||
const std::string h = hashWavContent(notWav);
|
||||
CHECK(!h.empty());
|
||||
CHECK(h.size() == 16);
|
||||
CHECK(h == hashBytes(notWav.data(), notWav.size()));
|
||||
|
||||
std::vector<std::uint8_t> empty;
|
||||
const std::string he = hashWavContent(empty);
|
||||
CHECK(he.size() == 16);
|
||||
CHECK(he == hashBytes(nullptr, 0));
|
||||
}
|
||||
|
||||
static void testHashWavContentListMetaSkipped() {
|
||||
// A LIST/INFO chunk (another common metadata chunk) is likewise skipped.
|
||||
const std::vector<float> audio = {1.0f, -1.0f, 0.5f};
|
||||
std::vector<std::uint8_t> listBody = {'I','N','F','O', 'x','x','x','x'};
|
||||
auto wavClean = buildMetaWav(1, 48000, audio);
|
||||
auto wavList = buildMetaWav(1, 48000, audio, true, "LIST", listBody);
|
||||
|
||||
// Content hashes must match: only the LIST chunk differs.
|
||||
CHECK(hashWavContent(wavClean) == hashWavContent(wavList));
|
||||
}
|
||||
|
||||
static void testHashWavContentDomainSeparationFromWholeFile() {
|
||||
// The content hash ('W'-prefixed) must not accidentally equal the whole-file
|
||||
// hash of the SAME bytes. This guards against the domain-separation prefix
|
||||
// being dropped or zeroed out.
|
||||
const std::vector<float> audio = {0.0f};
|
||||
auto wav = buildMetaWav(1, 44100, audio);
|
||||
const std::string contentHash = hashWavContent(wav);
|
||||
const std::string wholeHash = hashBytes(wav.data(), wav.size());
|
||||
CHECK(contentHash != wholeHash);
|
||||
}
|
||||
|
||||
static void testHashMatchesBuildOutput() {
|
||||
// The consolidation guarantee end-to-end: a WAV produced by the module's own
|
||||
// builder hashes as WAV content (not the whole-file fallback), so an imported
|
||||
// conversion and a captured render of identical audio can dedup-collapse.
|
||||
const std::vector<double> pcm = {0.25, -0.25};
|
||||
auto wav = buildFloat32Wav(1, 44100, 2, pcm);
|
||||
CHECK(hashWavContent(wav) != hashBytes(wav.data(), wav.size())); // chunk-aware path taken
|
||||
// And a metadata-bearing copy of the same audio content hashes identically.
|
||||
auto withMeta = buildMetaWav(1, 44100, {0.25f, -0.25f}, true, "bext",
|
||||
std::vector<std::uint8_t>(16, 0x7A));
|
||||
CHECK(hashWavContent(wav) == hashWavContent(withMeta));
|
||||
}
|
||||
|
||||
int main() {
|
||||
testParseCanonicalStereo();
|
||||
testParseMonoAndLeadingChunk();
|
||||
@@ -367,7 +587,21 @@ int main() {
|
||||
testTruncatePlanKeepZeroAndGrowRejected();
|
||||
testExtensiblePcmIntegerRejected();
|
||||
testExtensibleFloatAccepted();
|
||||
testPatchU32LEWritesLittleEndian();
|
||||
testBuildFloat32WavGoldenHeaderAndRoundTrip();
|
||||
testBuildFloat32WavShortInputRejectedByParse();
|
||||
testHashBytesOutputFormat();
|
||||
testHashBytesDeterministicAndDistinct();
|
||||
testHashBytesEmptyBufferIsNonEmpty();
|
||||
testHashWavContentIdenticalAudioSameHash();
|
||||
testHashWavContentDifferentAudioDifferentHash();
|
||||
testHashWavContentDifferentFmtDifferentHash();
|
||||
testHashWavContentNonWavFallsBackToWholeFile();
|
||||
testHashWavContentListMetaSkipped();
|
||||
testHashWavContentDomainSeparationFromWholeFile();
|
||||
testHashMatchesBuildOutput();
|
||||
|
||||
if (g_fail == 0) std::printf("All tests passed.\n");
|
||||
if (g_fail == 0) std::printf("wav_codec: all tests passed\n");
|
||||
else std::printf("wav_codec: %d CHECK(s) FAILED\n", g_fail);
|
||||
return g_fail ? 1 : 0;
|
||||
}
|
||||
Reference in New Issue
Block a user