From 09f7173db22bbde4f689852b363a05f3152bd949 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Wed, 29 Jul 2026 10:56:11 -0400 Subject: [PATCH] =?UTF-8?q?Q-W3:=20main.cpp=20=E2=86=92=20pointers+entry+d?= =?UTF-8?q?ispatch=20via=204=20capture=20hoists;=20one=20pure=20wav=5Fcode?= =?UTF-8?q?c=20RIFF=20owner;=20ICaptureBackend=20deleted;=20capture=5Freal?= =?UTF-8?q?time=20rename=20+=20finalize=20split;=20shared=20stampCaptureSa?= =?UTF-8?q?mple;=20makeUniqueTag=20gains=20monotonic=20counter=20(fixes=20?= =?UTF-8?q?same-second=20batch=20collisions).=2060/60=20green.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 2 +- CMakeLists.txt | 71 +- CONTEXT-ARCHIVE.md | 6 +- src/app/main.cpp | 1356 +---------------- src/core/capture/capture_paths.cpp | 112 +- src/core/capture/capture_paths.h | 34 +- ...altime_record.cpp => capture_realtime.cpp} | 7 +- .../{realtime_record.h => capture_realtime.h} | 9 +- src/core/capture/wav_codec.cpp | 333 ++++ src/core/capture/wav_codec.h | 172 +++ src/core/capture/wav_trim.cpp | 160 -- src/core/capture/wav_trim.h | 110 +- src/ingest.cpp | 74 +- src/shell/capture/capture.cpp | 149 +- src/shell/capture/capture.h | 111 +- src/shell/capture/capture_batch.cpp | 523 +++++++ src/shell/capture/capture_batch.h | 32 + src/shell/capture/capture_orchestrator.cpp | 487 ++++++ src/shell/capture/capture_orchestrator.h | 73 + .../capture/capture_realtime_finalize.cpp | 252 +++ src/shell/capture/capture_realtime_finalize.h | 42 + ...ealtime.cpp => capture_realtime_shell.cpp} | 304 +--- src/shell/capture/realtime_lifecycle.cpp | 101 ++ src/shell/capture/realtime_lifecycle.h | 56 + src/shell/capture/scope_resolve.cpp | 242 +++ src/shell/capture/scope_resolve.h | 71 + tests/test_capture_paths.cpp | 220 +-- ...e_record.cpp => test_capture_realtime.cpp} | 9 +- .../{test_wav_trim.cpp => test_wav_codec.cpp} | 280 +++- 29 files changed, 2972 insertions(+), 2426 deletions(-) rename src/core/capture/{realtime_record.cpp => capture_realtime.cpp} (95%) rename src/core/capture/{realtime_record.h => capture_realtime.h} (96%) create mode 100644 src/core/capture/wav_codec.cpp create mode 100644 src/core/capture/wav_codec.h delete mode 100644 src/core/capture/wav_trim.cpp create mode 100644 src/shell/capture/capture_batch.cpp create mode 100644 src/shell/capture/capture_batch.h create mode 100644 src/shell/capture/capture_orchestrator.cpp create mode 100644 src/shell/capture/capture_orchestrator.h create mode 100644 src/shell/capture/capture_realtime_finalize.cpp create mode 100644 src/shell/capture/capture_realtime_finalize.h rename src/shell/capture/{capture_realtime.cpp => capture_realtime_shell.cpp} (67%) create mode 100644 src/shell/capture/realtime_lifecycle.cpp create mode 100644 src/shell/capture/realtime_lifecycle.h create mode 100644 src/shell/capture/scope_resolve.cpp create mode 100644 src/shell/capture/scope_resolve.h rename tests/{test_realtime_record.cpp => test_capture_realtime.cpp} (97%) rename tests/{test_wav_trim.cpp => test_wav_codec.cpp} (55%) diff --git a/CLAUDE.md b/CLAUDE.md index 745d06c..2b8312f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/CMakeLists.txt b/CMakeLists.txt index 6449117..f643210 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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' diff --git a/CONTEXT-ARCHIVE.md b/CONTEXT-ARCHIVE.md index a2f4207..3e41e15 100644 --- a/CONTEXT-ARCHIVE.md +++ b/CONTEXT-ARCHIVE.md @@ -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 diff --git a/src/app/main.cpp b/src/app/main.cpp index 92f1daf..7200eaa 100644 --- a/src/app/main.cpp +++ b/src/app/main.cpp @@ -1,4 +1,3 @@ -#include "core/namespaces.h" // main.cpp — the SINGLE translation unit that OWNS the REAPER API pointers. // // This file is the entire contract between REAPER and the extension: @@ -14,6 +13,11 @@ // Exactly ONE .cpp defines REAPERAPI_IMPLEMENT (this one) — that allocates // storage for those global pointers. Every other .cpp includes // reaper_plugin_functions.h WITHOUT the define and gets `extern` declarations. +// +// Since Q-W3 this TU is ONLY pointers + entry + dispatch: the capture +// orchestration it used to carry lives in shell/capture/ (capture_orchestrator / +// capture_batch / scope_resolve / realtime_lifecycle). The registration blocks +// below are slated for Q-W6's registration table. #define REAPERAPI_IMPLEMENT #include "reaper_plugin.h" @@ -21,30 +25,24 @@ #include #include -#include #include -#include - #include #include "actions.h" -#include "core/version/app_version.h" -#include "core/model/bank_model.h" -#include "shell/panel/panel_bank_ops.h" // selection read seam (insert action) -#include "shell/panel/panel_input.h" // bankPanelRefresh / project-load notify / tail seam -#include "shell/panel/panel_window.h" // panel lifecycle (init/toggle/shutdown) -#include "core/capture/batch_capture.h" -#include "shell/capture/capture.h" +#include "core/capture/render_settings.h" // captureActionTable +#include "core/version/app_version.h" // channelCommandId / channelActionName / appVersion #include "ingest.h" -#include "shell/capture/insert.h" #include "persist.h" -#include "core/model/provenance.h" -#include "shell/capture/provenance_shell.h" -#include "core/capture/render_settings.h" -#include "shell/capture/track_guid.h" -#include "shell/view/view.h" +#include "shell/capture/capture_batch.h" // batch + recapture action bodies +#include "shell/capture/capture_orchestrator.h" // single-capture / realtime / insert action bodies +#include "shell/capture/realtime_lifecycle.h" // in-flight realtime state + tick driver +#include "shell/panel/panel_input.h" // bankPanelRefresh / bankPanelNotifyProjectLoaded +#include "shell/panel/panel_window.h" // panel lifecycle (init/toggle/open-query/shutdown) +#include "shell/view/view.h" // reconcileManagedLanes / applyMode -#include // project-dir derivation for provenance parent resolution +namespace capture = reasampler::capture; +using reasampler::version::channelActionName; +using reasampler::version::channelCommandId; // Persistent action-id family (Phase V, V4 — channel-qualified). Every bindable action // mints its command id from commandIdPrefix() + a per-action SUFFIX, and its Actions-list @@ -65,7 +63,7 @@ static std::deque g_idStore; // Appended-to only during startup registration and read on unload; never cleared until // process exit, and deque guarantees the returned pointer stays valid. static const char* internCmdId(const std::string& suffix) { - g_idStore.push_back(reasampler::channelCommandId(suffix)); + g_idStore.push_back(channelCommandId(suffix)); return g_idStore.back().c_str(); } @@ -76,7 +74,8 @@ reaper_plugin_info_t* g_rec = nullptr; // REAPER's dispatch struct // ---- Capture action family (two FX scopes) --------------------------------- // Two bindable SCOPE actions from captureActionTable() (render_settings, pure): // capture item / track. Each infers its range (razor-else-time) and enforces the -// FX-scope invariant via FX-bypass-around-render (FxBypassGuard): +// FX-scope invariant via FX-bypass-around-render (FxBypassGuard, now in +// capture_orchestrator): // Item -> take/item FX only (bypass the item's track + ancestors + master). // Track -> item FX + track's own FX (bypass ancestors + master). // There is NO master scope — to capture the master you render a track. (The master @@ -150,25 +149,19 @@ static int g_cmdCaptureTrackRealtime = 0; // Command id for the M10 "re-capture from source" action. NEW FOREVER-STABLE string // (suffix RECAPTURE_FROM_SOURCE). Regenerates the bank panel's selected PROVENANCED // sample from its recorded source's current state and updates the Sample in place — -// BANK-ONLY, never places on the timeline (load-bearing principle). Reports the no- -// provenance / vanished-source / drift cases to the console (a direct response to an -// explicit action, allowed by the console policy). +// BANK-ONLY, never places on the timeline (load-bearing principle). static int g_cmdRecaptureFromSource = 0; // Command id for the S8 "capture selected item / time-selection into bank + assign" // action. NEW FOREVER-STABLE string (suffix CAPTURE_ITEM_ASSIGN). Reuses the offline -// Item-scope capture path (RunCapture) verbatim — same razor-else-time range, same -// FX-scope neutralize, same bank/persist landing — then writes an S8 assignment request -// so the active sampler instance plays the just-captured sample on its next reload. NEVER -// inserts a timeline item (the capture/placement separation holds; assign is a bank-index -// + instance-selection act). Lives in the capture family (not the ingest family) because -// it leans on main.cpp's capture render machinery, which is not exposed cross-module. +// Item-scope capture path (RunCapture) verbatim, then writes an S8 assignment request. +// Lives in the capture family (not the ingest family) because it leans on the capture +// render machinery (capture_orchestrator). static int g_cmdCaptureItemAssign = 0; // Command id for the M8 "cancel realtime capture" action. FOREVER-STABLE string. -// Aborts the in-flight realtime capture (stop + restore, non-destructive) so a user -// who started a long capture can bail without waiting for the range end or hunting for -// the transport-stop. No-op (with a note) when nothing is in flight. +// Aborts the in-flight realtime capture (stop + restore, non-destructive). No-op +// (with a note) when nothing is in flight. static int g_cmdCancelRealtime = 0; // Command id for the Phase V "show version" action. FOREVER-STABLE string. On demand @@ -186,89 +179,6 @@ static int g_cmdShowVersion = 0; // with the .rpp. Replaces the M3 session-only g_bank. static reasampler::ReaSamplerSession g_session; -// --- M8 in-flight realtime capture (async, timer-driven) -------------------- -// 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 (g_rtBackend.begin), which -// returns immediately with the in-flight state owned here; OnTimer drives it -// (g_rtBackend.tick) each tick until a terminal verdict; then this pointer is -// cleared. Non-null == a capture is in progress (used to reject a second one, and to -// abort on project switch / unload). -static reasampler::RealtimeRecordBackend g_rtBackend; -static reasampler::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. -static ReaProject* g_rtCaptureProject = nullptr; - -// Commit a finished realtime capture (a Done tick/abort with an Ok result): add the -// Sample to the ACTIVE bank (g_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. -static void CommitRealtimeResult(const reasampler::CaptureResult& res) -{ - if (res.status != reasampler::CaptureStatus::Ok) - { - ShowConsoleMsg(("ReaSampler realtime capture failed: " + res.message + "\n").c_str()); - return; - } - g_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). - g_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. - g_session.bumpBankGeneration(); - g_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. -static void DriveRealtimeCapture() -{ - 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) - { - reasampler::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 == reasampler::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; - } - - reasampler::RealtimeTickResult r = g_rtBackend.tick(*g_rtCapture); - if (r.status == reasampler::RealtimeTickStatus::InProgress) return; - - // Terminal (Done or Failed): commit/log and drop the in-flight state. - CommitRealtimeResult(r.result); - g_rtCapture.reset(); - g_rtCaptureProject = nullptr; -} - // The timer callback REAPER runs periodically (registered via "timer"). It only // forwards to the session poll — cheap per tick (reads the active project id and // its .rpp path, acts only on a change). @@ -276,7 +186,9 @@ static void OnTimer() { // Advance any in-flight realtime capture FIRST, so a project switch is caught and // the capture torn down/restored before session.poll() reacts to that switch. - DriveRealtimeCapture(); + // LOAD-BEARING (CONTEXT.md §Phase Q): the idle fast-path is a SINGLE POINTER + // TEST — the cross-TU drive call is made only when a capture is in flight. + if (capture::g_rtCapture) capture::DriveRealtimeCapture(g_session); g_session.poll(); @@ -361,1155 +273,6 @@ static project_config_extension_t g_projectConfig{ nullptr, // userData }; -// --- Scope-action source resolution ----------------------------------------- -// The three scope actions (item / track / master) each resolve to (1) an exact -// render range in project seconds — razor-else-time, inferred here — and (2) the -// set of source TRACKS whose ancestor chains drive the FX-bypass plan. All reads -// are non-destructive: selection, razor, and time selection are read, never -// mutated. Returning false means "nothing to capture" (empty selection / no -// range); the caller reports it and writes nothing. - -// 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 sourceTracks; // item-owning tracks / selected tracks - std::vector trackGuids; // canonical GUIDs of sourceTracks -}; - -// Time selection -> exact bounds (no rounding). GetSet_LoopTimeRange(isSet=false, -// isLoop=false) reads the current time selection. -static bool resolveTimeSelection(double& start, double& end) -{ - start = 0.0; end = 0.0; - GetSet_LoopTimeRange(false, false, &start, &end, false); - return end > start; -} - -// 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. -static bool resolveRazorRange(double& start, double& end) -{ - std::vector allRanges; - const int n = CountTracks(nullptr); - for (int i = 0; i < n; ++i) - { - MediaTrack* tr = GetTrack(nullptr, i); - if (!tr) continue; - std::vector buf(8192, '\0'); - if (!GetSetMediaTrackInfo_String(tr, "P_RAZOREDITS", buf.data(), false)) - continue; - std::vector ranges = - reasampler::parseRazorEdits(std::string(buf.data())); - for (auto& r : ranges) allRanges.push_back(r); - } - if (allRanges.empty()) return false; - reasampler::RazorRange u = reasampler::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. -static bool resolveRange(double& start, double& end, std::string& why) -{ - double rzStart = 0.0, rzEnd = 0.0; - const bool hasRazor = resolveRazorRange(rzStart, rzEnd); - if (reasampler::inferRangeSource(hasRazor) == reasampler::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; -} - -// 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. -static std::string currentProjectDir() -{ - std::vector buf(4096, '\0'); - EnumProjects(-1, buf.data(), static_cast(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; -} - -// Maps a capture FX scope onto the pure provenance scope (kept decoupled so the -// pure provenance module does not depend on render_settings). -static reasampler::ProvenanceScope provenanceScopeFor(reasampler::CaptureScope scope) -{ - return scope == reasampler::CaptureScope::Item ? reasampler::ProvenanceScope::Item - : reasampler::ProvenanceScope::Track; -} - -// 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. -static std::optional buildCaptureProvenance( - const reasampler::CaptureRequest& req, - reasampler::CaptureScope scope, - const ResolvedSource& src) -{ - const std::string projectDir = currentProjectDir(); - const std::vector bankFiles = - reasampler::bankFileRefs(g_session.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 sourceFiles = - scope == reasampler::CaptureScope::Item - ? reasampler::selectedItemSourceFiles() - : reasampler::trackItemSourceFiles(src.sourceTracks, req.startSeconds, - req.endSeconds); - - const std::optional parentId = - reasampler::detectParent(sourceFiles, bankFiles); - if (!parentId) return std::nullopt; // not a resample-from-sample — no provenance - - reasampler::CaptureRecipe recipe; - recipe.scope = provenanceScopeFor(scope); - recipe.sourceMode = static_cast(req.sourceMode); - recipe.startSeconds = req.startSeconds; - recipe.endSeconds = req.endSeconds; - recipe.tailMode = static_cast(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 == reasampler::CaptureScope::Item) { - const int n = CountSelectedMediaItems(nullptr); - std::vector items; - items.reserve(static_cast(n < 0 ? 0 : n)); - for (int i = 0; i < n; ++i) { - MediaItem* it = GetSelectedMediaItem(nullptr, i); - if (it) items.push_back(it); - } - recipe.fxChainIdentity = reasampler::fxChainIdentityForItems(items); - } else { - std::vector perTrack; - perTrack.reserve(src.sourceTracks.size()); - for (MediaTrack* tr : src.sourceTracks) - perTrack.push_back(reasampler::fxChainIdentityForTrack(tr)); - recipe.fxChainIdentity = reasampler::combineChainIdentities(perTrack); - } - - reasampler::Provenance prov; - prov.parentSampleId = *parentId; - prov.fxChainSnapshot = reasampler::buildFingerprint(recipe); - return prov; -} - -// Collects the selected tracks (Track scope) into out.sourceTracks + GUIDs. -static 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 = reasampler::guidString(tr); - if (!g.empty()) out.trackGuids.push_back(std::move(g)); - } - return !out.sourceTracks.empty(); -} - -// 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. -static 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 = reasampler::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. -static bool ResolveScopeSource(reasampler::CaptureScope scope, - ResolvedSource& out, std::string& why) -{ - using reasampler::CaptureScope; - 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); -} - -// --- 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(reasampler::CaptureScope scope, - const std::vector& sourceTracks, - ReaProject* proj) - { - const reasampler::FxBypassPlan plan = reasampler::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 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) - } -}; - -// 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. -static reasampler::CaptureResult renderOffline( - reasampler::CaptureScope scope, - const std::vector& sourceTracks, - const reasampler::CaptureRequest& req) -{ - ReaProject* proj = EnumProjects(-1, nullptr, 0); - FxBypassGuard fxGuard(scope, sourceTracks, proj); - reasampler::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. -static reasampler::CaptureResult captureAndIndexOne( - reasampler::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 reasampler::TailSetting tail = reasampler::bankPanelTailSetting(); - - reasampler::CaptureRequest req; - req.sourceMode = reasampler::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 = reasampler::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 prov = - buildCaptureProvenance(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. - reasampler::CaptureResult res = renderOffline(scope, src.sourceTracks, req); - if (res.status != reasampler::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: g_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 reasampler::AddResult addResult = g_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). - g_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 == reasampler::AddResult::Collapsed && !res.sample.contentHash.empty()) - { - if (const reasampler::Sample* existing = - g_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). -static std::string RunCapture(const reasampler::CaptureActionDef& def) -{ - ResolvedSource src; - std::string why; - if (!ResolveScopeSource(def.scope, src, why)) - { - ShowConsoleMsg(("ReaSampler capture: " + why + ".\n").c_str()); - return {}; - } - - reasampler::CaptureResult res = - captureAndIndexOne(def.scope, src, def.baseName, src.startSeconds, src.endSeconds); - if (res.status != reasampler::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). - g_session.bumpBankGeneration(); - g_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. -static void RunCaptureItemAssign() -{ - // 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(reasampler::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. - reasampler::ingestAssignActiveInstance(g_session.book().activeBankId(), sampleId); - Undo_EndBlock2(nullptr, "ReaSampler: capture + assign to active instance", - UNDO_STATE_MISCCFG); - - reasampler::bankPanelRefresh(); - ShowConsoleMsg("ReaSampler ingest: captured into the bank and assigned to the active " - "instance.\n"); -} - -// --- 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: the offline backend's unique tag is 1-second-granular. The -// per-item render already takes real wall-clock time (REAPER's offline-render dialog per -// unit), so consecutive units naturally land in distinct seconds; belt-and-braces, each -// unit's baseName also carries its ordinal ("item-1", "item-2", ...) so two units are -// never asked to write the same stem within one batch. (Residual, DAW-verify: two BATCHES -// fired within the same wall-clock second with identical ordinals could still collide — -// unreachable in practice given the per-unit render latency, noted for completeness.) - -// 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 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. -static 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); -} - -// 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). -static void RunBatchCaptureItems() -{ - // 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 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 ranges; - ranges.reserve(itemUnits.size()); - for (const ItemUnit& u : itemUnits) - ranges.push_back({u.start, u.end}); - const std::vector plan = reasampler::planCaptureUnits(ranges); - - reasampler::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 reasampler::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 = reasampler::guidString(u.track); !g.empty()) - src.trackGuids.push_back(std::move(g)); - - const std::string baseName = "item-" + std::to_string(unit.ordinal); - reasampler::CaptureResult res = captureAndIndexOne( - reasampler::CaptureScope::Item, src, baseName, - unit.startSeconds, unit.endSeconds); - - const bool ok = (res.status == reasampler::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) { - g_session.bumpBankGeneration(); - g_session.saveToActiveProject(); - } - - ShowConsoleMsg((outcome.summaryLine("item") + "\n").c_str()); -} - -// 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. -static std::vector> collectRazorAreas() -{ - std::vector> areas; - const int n = CountTracks(nullptr); - for (int i = 0; i < n; ++i) - { - MediaTrack* tr = GetTrack(nullptr, i); - if (!tr) continue; - std::vector buf(8192, '\0'); - if (!GetSetMediaTrackInfo_String(tr, "P_RAZOREDITS", buf.data(), false)) - continue; - for (const reasampler::RazorRange& r : - reasampler::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 selected_; -}; - -// 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. -static void RunBatchCaptureRazor() -{ - const std::vector> areas = - collectRazorAreas(); - if (areas.empty()) - { - ShowConsoleMsg("ReaSampler batch capture: make at least one razor area first.\n"); - return; - } - - std::vector ranges; - ranges.reserve(areas.size()); - for (const auto& a : areas) - ranges.push_back({a.second.startSeconds, a.second.endSeconds}); - const std::vector plan = reasampler::planCaptureUnits(ranges); - - reasampler::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 reasampler::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 = reasampler::guidString(tr); !g.empty()) - src.trackGuids.push_back(std::move(g)); - - const std::string baseName = "razor-" + std::to_string(unit.ordinal); - reasampler::CaptureResult res = captureAndIndexOne( - reasampler::CaptureScope::Track, src, baseName, - unit.startSeconds, unit.endSeconds); - - const bool ok = (res.status == reasampler::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) { - g_session.bumpBankGeneration(); - g_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). -static void RunRecaptureFromSource() -{ - const std::vector selected = reasampler::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 = reasampler::bankPanelSelectedSourceBankId(); - const reasampler::Bank* bank = g_session.book().bank(srcBankId); - const reasampler::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 recipe = - reasampler::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 sourceTracks; - for (const std::string& g : recipe->trackGuids) - { - MediaTrack* tr = reasampler::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 reasampler::CaptureScope scope = - recipe->scope == reasampler::ProvenanceScope::Item - ? reasampler::CaptureScope::Item - : reasampler::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). - reasampler::CaptureRequest req; - req.sourceMode = static_cast(recipe->sourceMode); - req.startSeconds = recipe->startSeconds; - req.endSeconds = recipe->endSeconds; - req.wetDry = 1.0; - req.tailMode = static_cast(recipe->tailMode); - req.tailMs = recipe->tailMs; - req.sampleRate = recipe->sampleRate; - req.channelCount = recipe->channelCount; - req.bitDepth = reasampler::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 == reasampler::CaptureScope::Item) { - const int n = CountSelectedMediaItems(nullptr); - std::vector items; - items.reserve(static_cast(n < 0 ? 0 : n)); - for (int i = 0; i < n; ++i) { - MediaItem* it = GetSelectedMediaItem(nullptr, i); - if (it) items.push_back(it); - } - currentIdentity = reasampler::fxChainIdentityForItems(items); - } else { - std::vector perTrackNow; - perTrackNow.reserve(sourceTracks.size()); - for (MediaTrack* tr : sourceTracks) - perTrackNow.push_back(reasampler::fxChainIdentityForTrack(tr)); - currentIdentity = reasampler::combineChainIdentities(perTrackNow); - } - const bool drifted = (currentIdentity != recipe->fxChainIdentity); - - // Render (bank-only; renderOffline never touches the timeline). - reasampler::CaptureResult res = renderOffline(scope, sourceTracks, req); - if (res.status != reasampler::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. - reasampler::CaptureRecipe refreshed = *recipe; - refreshed.fxChainIdentity = currentIdentity; - - reasampler::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. - reasampler::Provenance prov; - prov.parentSampleId = recordedParentId; - prov.fxChainSnapshot = reasampler::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 = g_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. - g_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. - g_session.bumpBankGeneration(); - const bool persisted = g_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 - } - - reasampler::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"); -} - -// STARTS the REALTIME track capture and returns immediately — the record runs across -// timer ticks (DriveRealtimeCapture), 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.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). -static void RunCaptureRealtimeTrack() -{ - 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(reasampler::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 reasampler::TailSetting tail = reasampler::bankPanelTailSetting(); - - reasampler::CaptureRequest req; - req.sourceMode = reasampler::SourceMode::SelectedTracks; // realtime track scope - req.startSeconds = src.startSeconds; // exact bounds — no rounding - req.endSeconds = src.endSeconds; - req.wetDry = 1.0; // fully wet (post-fader tap) - req.tailMode = tail.mode; // None / Auto / Manual, from the panel toggle - req.tailMs = tail.manualMs; // Manual-only (pre-clamped); ignored for None/Auto - req.sampleRate = 0; // follow project rate - req.channelCount = 2; - req.bitDepth = reasampler::WavBitDepth::Float32; - req.baseName = "realtime"; - req.trackGuids = src.trackGuids; // provenance on the Sample - - reasampler::CaptureResult failure; - reasampler::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. -static void RunCancelRealtime() -{ - if (!g_rtCapture) - { - ShowConsoleMsg("ReaSampler: no realtime capture in progress to cancel.\n"); - return; - } - reasampler::RealtimeTickResult r = g_rtBackend.abort(*g_rtCapture); - if (r.status == reasampler::RealtimeTickStatus::Done) - CommitRealtimeResult(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. -static void RunInsertSelected(bool conform) -{ - reasampler::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 ? reasampler::TempoConform::Ratio1x : reasampler::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.) - - reasampler::InsertResult res = reasampler::runInsert(&g_session, req); - - switch (res.status) - { - case reasampler::InsertStatus::Ok: - break; // success — no console chatter - case reasampler::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 reasampler::InsertStatus::NoProject: - ShowConsoleMsg("ReaSampler insert: no saved project, so the bank has no location.\n"); - break; - case reasampler::InsertStatus::NothingResolved: - ShowConsoleMsg("ReaSampler insert: selected sample(s) could not be resolved to a file.\n"); - break; - } -} - // REAPER calls this for EVERY action fired anywhere; claim only our own id, // return false otherwise so REAPER keeps looking. static bool OnHookCommand(int command, int /*flag*/) @@ -1520,22 +283,22 @@ static bool OnHookCommand(int command, int /*flag*/) for (std::size_t i = 0; i < g_captureCmdIds.size(); ++i) if (command == g_captureCmdIds[i]) { - RunCapture(reasampler::captureActionTable()[i]); + capture::RunCapture(g_session, capture::captureActionTable()[i]); return true; } if (command == g_cmdToggleBankPanel) { reasampler::bankPanelToggle(); return true; } - if (command == g_cmdCaptureItemAssign) { RunCaptureItemAssign(); return true; } - if (command == g_cmdInsertSelected) { RunInsertSelected(false); return true; } - if (command == g_cmdInsertSelectedConform) { RunInsertSelected(true); return true; } - if (command == g_cmdCaptureBatchItems) { RunBatchCaptureItems(); return true; } - if (command == g_cmdCaptureBatchRazor) { RunBatchCaptureRazor(); return true; } - if (command == g_cmdCaptureTrackRealtime) { RunCaptureRealtimeTrack(); return true; } - if (command == g_cmdCancelRealtime) { RunCancelRealtime(); return true; } - if (command == g_cmdRecaptureFromSource) { RunRecaptureFromSource(); return true; } + if (command == g_cmdCaptureItemAssign) { capture::RunCaptureItemAssign(g_session); return true; } + if (command == g_cmdInsertSelected) { capture::RunInsertSelected(g_session, false); return true; } + if (command == g_cmdInsertSelectedConform) { capture::RunInsertSelected(g_session, true); return true; } + if (command == g_cmdCaptureBatchItems) { capture::RunBatchCaptureItems(g_session); return true; } + if (command == g_cmdCaptureBatchRazor) { capture::RunBatchCaptureRazor(g_session); return true; } + if (command == g_cmdCaptureTrackRealtime) { capture::RunCaptureRealtimeTrack(g_session); return true; } + if (command == g_cmdCancelRealtime) { capture::RunCancelRealtime(g_session); return true; } + if (command == g_cmdRecaptureFromSource) { capture::RunRecaptureFromSource(g_session); return true; } if (command == g_cmdShowVersion) { // On-demand version readout — the ONLY version output on any path. - ShowConsoleMsg(("ReaSampler " + reasampler::appVersion() + "\n").c_str()); + ShowConsoleMsg(("ReaSampler " + reasampler::version::appVersion() + "\n").c_str()); return true; } // Design View action family (D4). Claims only its own ids; returns false for the @@ -1610,13 +373,7 @@ extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT( // 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. Commit whatever was captured (best effort) before tearing down. - if (g_rtCapture) - { - reasampler::RealtimeTickResult r = g_rtBackend.abort(*g_rtCapture); - CommitRealtimeResult(r.result); - g_rtCapture.reset(); - g_rtCaptureProject = nullptr; - } + capture::AbortRealtimeCaptureForUnload(g_session); g_rec->Register("-timer", (void*)&OnTimer); g_rec->Register("-projectconfig", (void*)&g_projectConfig); @@ -1655,13 +412,12 @@ extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT( // '-'-prefixed strings (per the contract). The command id is re-composed from // the same suffix + channel prefix used at register — identical string. { - const auto& table = reasampler::captureActionTable(); + const auto& table = capture::captureActionTable(); for (std::size_t i = 0; i < table.size(); ++i) { if (i < g_captureAccels.size()) g_rec->Register("-gaccel", (void*)&g_captureAccels[i]); - const std::string id = - reasampler::channelCommandId(table[i].commandSuffix); + const std::string id = channelCommandId(table[i].commandSuffix); g_rec->Register("-command_id", (void*)id.c_str()); } } @@ -1670,7 +426,7 @@ extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT( // per channel so a beta clears beta-qualified retired ids, stable clears its own. for (const char* suffix : kRetiredCaptureCmdSuffixes) { - const std::string id = reasampler::channelCommandId(suffix); + const std::string id = channelCommandId(suffix); g_rec->Register("-command_id", (void*)id.c_str()); } } @@ -1698,7 +454,7 @@ extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT( // g_captureAccels must be sized BEFORE the loop and never reallocated after — // REAPER holds a pointer to each element until we mirror-unregister it. { - const auto& table = reasampler::captureActionTable(); + const auto& table = capture::captureActionTable(); g_captureCmdIds.assign(table.size(), 0); g_captureAccels.assign(table.size(), gaccel_register_t{}); g_captureDescs.assign(table.size(), std::string{}); @@ -1712,8 +468,7 @@ extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT( g_captureCmdIds[i] = cmd; if (cmd) { - g_captureDescs[i] = - reasampler::channelActionName(table[i].descriptionPhrase); + g_captureDescs[i] = channelActionName(table[i].descriptionPhrase); g_captureAccels[i].accel.cmd = cmd; g_captureAccels[i].desc = g_captureDescs[i].c_str(); rec->Register("gaccel", (void*)&g_captureAccels[i]); @@ -1732,7 +487,7 @@ extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT( g_cmdToggleBankPanel = rec->Register("command_id", (void*)g_idToggleBankPanel); if (g_cmdToggleBankPanel) { - g_descToggleBankPanel = reasampler::channelActionName("toggle bank panel"); + g_descToggleBankPanel = channelActionName("toggle bank panel"); g_accelToggleBankPanel.accel.cmd = g_cmdToggleBankPanel; g_accelToggleBankPanel.desc = g_descToggleBankPanel.c_str(); rec->Register("gaccel", (void*)&g_accelToggleBankPanel); @@ -1744,13 +499,13 @@ extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT( // writes an assignment request so the active instance plays the new sample. Channel- // qualified FOREVER-STABLE id (suffix CAPTURE_ITEM_ASSIGN). MIDI-bindable like every // capture action. Registered in the capture family (main.cpp) because it leans on the - // capture render machinery here; the other two ingest surfaces live in the ingest family + // capture render machinery; the other two ingest surfaces live in the ingest family // (Media-Explorer import) and the panel drop callback. g_idCaptureItemAssign = internCmdId("CAPTURE_ITEM_ASSIGN"); g_cmdCaptureItemAssign = rec->Register("command_id", (void*)g_idCaptureItemAssign); if (g_cmdCaptureItemAssign) { - g_descCaptureItemAssign = reasampler::channelActionName( + g_descCaptureItemAssign = channelActionName( "capture selected item into bank + assign to active instance"); g_accelCaptureItemAssign.accel.cmd = g_cmdCaptureItemAssign; g_accelCaptureItemAssign.desc = g_descCaptureItemAssign.c_str(); @@ -1764,8 +519,7 @@ extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT( g_cmdInsertSelected = rec->Register("command_id", (void*)g_idInsertSelected); if (g_cmdInsertSelected) { - g_descInsertSelected = - reasampler::channelActionName("insert selected sample at edit cursor"); + g_descInsertSelected = channelActionName("insert selected sample at edit cursor"); g_accelInsertSelected.accel.cmd = g_cmdInsertSelected; g_accelInsertSelected.desc = g_descInsertSelected.c_str(); rec->Register("gaccel", (void*)&g_accelInsertSelected); @@ -1775,7 +529,7 @@ extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT( g_cmdInsertSelectedConform = rec->Register("command_id", (void*)g_idInsertSelectedConform); if (g_cmdInsertSelectedConform) { - g_descInsertSelectedConform = reasampler::channelActionName( + g_descInsertSelectedConform = channelActionName( "insert selected sample at edit cursor (conform to tempo)"); g_accelInsertSelectedConform.accel.cmd = g_cmdInsertSelectedConform; g_accelInsertSelectedConform.desc = g_descInsertSelectedConform.c_str(); @@ -1791,7 +545,7 @@ extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT( if (g_cmdCaptureBatchItems) { g_descCaptureBatchItems = - reasampler::channelActionName("batch capture selected items (one per item)"); + channelActionName("batch capture selected items (one per item)"); g_accelCaptureBatchItems.accel.cmd = g_cmdCaptureBatchItems; g_accelCaptureBatchItems.desc = g_descCaptureBatchItems.c_str(); rec->Register("gaccel", (void*)&g_accelCaptureBatchItems); @@ -1802,7 +556,7 @@ extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT( if (g_cmdCaptureBatchRazor) { g_descCaptureBatchRazor = - reasampler::channelActionName("batch capture razor areas (one per area)"); + channelActionName("batch capture razor areas (one per area)"); g_accelCaptureBatchRazor.accel.cmd = g_cmdCaptureBatchRazor; g_accelCaptureBatchRazor.desc = g_descCaptureBatchRazor.c_str(); rec->Register("gaccel", (void*)&g_accelCaptureBatchRazor); @@ -1817,7 +571,7 @@ extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT( if (g_cmdCaptureTrackRealtime) { g_descCaptureTrackRealtime = - reasampler::channelActionName("capture selected track (realtime)"); + channelActionName("capture selected track (realtime)"); g_accelCaptureTrackRealtime.accel.cmd = g_cmdCaptureTrackRealtime; g_accelCaptureTrackRealtime.desc = g_descCaptureTrackRealtime.c_str(); rec->Register("gaccel", (void*)&g_accelCaptureTrackRealtime); @@ -1829,7 +583,7 @@ extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT( g_cmdCancelRealtime = rec->Register("command_id", (void*)g_idCancelRealtime); if (g_cmdCancelRealtime) { - g_descCancelRealtime = reasampler::channelActionName("cancel realtime capture"); + g_descCancelRealtime = channelActionName("cancel realtime capture"); g_accelCancelRealtime.accel.cmd = g_cmdCancelRealtime; g_accelCancelRealtime.desc = g_descCancelRealtime.c_str(); rec->Register("gaccel", (void*)&g_accelCancelRealtime); @@ -1843,7 +597,7 @@ extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT( g_cmdRecaptureFromSource = rec->Register("command_id", (void*)g_idRecaptureFromSource); if (g_cmdRecaptureFromSource) { - g_descRecaptureFromSource = reasampler::channelActionName("re-capture from source"); + g_descRecaptureFromSource = channelActionName("re-capture from source"); g_accelRecaptureFromSource.accel.cmd = g_cmdRecaptureFromSource; g_accelRecaptureFromSource.desc = g_descRecaptureFromSource.c_str(); rec->Register("gaccel", (void*)&g_accelRecaptureFromSource); @@ -1857,7 +611,7 @@ extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT( g_cmdShowVersion = rec->Register("command_id", (void*)g_idShowVersion); if (g_cmdShowVersion) { - g_descShowVersion = reasampler::channelActionName("show version"); + g_descShowVersion = channelActionName("show version"); g_accelShowVersion.accel.cmd = g_cmdShowVersion; g_accelShowVersion.desc = g_descShowVersion.c_str(); rec->Register("gaccel", (void*)&g_accelShowVersion); diff --git a/src/core/capture/capture_paths.cpp b/src/core/capture/capture_paths.cpp index 175f40a..705b28f 100644 --- a/src/core/capture/capture_paths.cpp +++ b/src/core/capture/capture_paths.cpp @@ -2,119 +2,13 @@ #include #include -#include -#include -#include // std::memcmp #include -#include 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(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(h)); - return std::string(buf); -} - -std::string hashWavContent(const std::vector& 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(bytes[off]) | - (static_cast(bytes[off + 1]) << 8) | - (static_cast(bytes[off + 2]) << 16) | - (static_cast(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(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('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(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; diff --git a/src/core/capture/capture_paths.h b/src/core/capture/capture_paths.h index 1cb9f6c..bd188e3 100644 --- a/src/core/capture/capture_paths.h +++ b/src/core/capture/capture_paths.h @@ -34,37 +34,9 @@ struct BankPaths { std::string fileStem; // (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& 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). diff --git a/src/core/capture/realtime_record.cpp b/src/core/capture/capture_realtime.cpp similarity index 95% rename from src/core/capture/realtime_record.cpp rename to src/core/capture/capture_realtime.cpp index 9a754c4..2ff05e4 100644 --- a/src/core/capture/realtime_record.cpp +++ b/src/core/capture/capture_realtime.cpp @@ -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 { diff --git a/src/core/capture/realtime_record.h b/src/core/capture/capture_realtime.h similarity index 96% rename from src/core/capture/realtime_record.h rename to src/core/capture/capture_realtime.h index 76e551a..b1da9c9 100644 --- a/src/core/capture/realtime_record.h +++ b/src/core/capture/capture_realtime.h @@ -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: // diff --git a/src/core/capture/wav_codec.cpp b/src/core/capture/wav_codec.cpp new file mode 100644 index 0000000..99d76fb --- /dev/null +++ b/src/core/capture/wav_codec.cpp @@ -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 // std::snprintf (hash hex render) +#include // 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& b, std::size_t off) { + return static_cast(b[off] | (b[off + 1] << 8)); +} +std::uint32_t readU32LE(const std::vector& b, std::size_t off) { + return static_cast(b[off]) | + (static_cast(b[off + 1]) << 8) | + (static_cast(b[off + 2]) << 16) | + (static_cast(b[off + 3]) << 24); +} + +bool tagEquals(const std::vector& b, std::size_t off, const char* tag) { + return off + 4 <= b.size() && std::memcmp(b.data() + off, tag, 4) == 0; +} + +// WAVE format tags we accept as 32-bit float (see wav_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(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& 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& 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& 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 extractFloatFrames(const std::vector& bytes, + const WavLayout& layout, + std::size_t startFrame, + std::size_t frameCount) { + std::vector out; + if (!layout.valid) return out; + + const std::size_t bytesPerFrame = + static_cast(layout.channelCount) * 4u; + const std::size_t totalFrames = layout.frameCount(); + if (startFrame >= totalFrames) return out; + + // Clamp the requested span to the frames that actually exist. + const std::size_t avail = totalFrames - startFrame; + const std::size_t frames = (frameCount < avail) ? frameCount : avail; + if (frames == 0) return out; + + const std::size_t firstByte = + layout.dataByteOffset + startFrame * bytesPerFrame; + out.resize(frames * layout.channelCount); + // memcpy each float (LE on target hosts — see header's byte-order note). + for (std::size_t i = 0; i < out.size(); ++i) { + float f = 0.0f; + std::memcpy(&f, bytes.data() + firstByte + i * 4u, 4u); + out[i] = f; + } + return out; +} + +WavTruncatePlan planWavTruncate(const WavLayout& layout, std::size_t keptFrames) { + WavTruncatePlan plan; + if (!layout.valid) return plan; + + const std::size_t totalFrames = layout.frameCount(); + if (keptFrames > totalFrames) return plan; // never grow + + const std::size_t bytesPerFrame = + static_cast(layout.channelCount) * 4u; + const std::size_t keptDataBytes = keptFrames * bytesPerFrame; + + plan.valid = true; + plan.newFileByteLength = layout.dataByteOffset + keptDataBytes; + plan.dataSizeFieldOffset = layout.dataSizeFieldOffset; + plan.newDataSize = static_cast(keptDataBytes); + plan.riffSizeFieldOffset = layout.riffSizeFieldOffset; + // RIFF size counts everything after the 8-byte "RIFF"+size prefix. + plan.newRiffSize = static_cast(plan.newFileByteLength - 8); + return plan; +} + +void patchU32LE(std::vector& bytes, std::size_t off, std::uint32_t v) { + bytes[off + 0] = static_cast(v & 0xFF); + bytes[off + 1] = static_cast((v >> 8) & 0xFF); + bytes[off + 2] = static_cast((v >> 16) & 0xFF); + bytes[off + 3] = static_cast((v >> 24) & 0xFF); +} + +std::vector buildFloat32Wav(int nch, std::uint32_t rate, + std::size_t frameCount, + const std::vector& interleaved) { + const std::size_t sampleCount = frameCount * static_cast(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(36u + dataBytesCount); // 4("WAVE")+24(fmt chunk)+8(data hdr)+data + + std::vector out; + out.reserve(44u + dataBytesCount); + + auto putU16 = [&](std::uint16_t v) { + out.push_back(static_cast(v & 0xFF)); + out.push_back(static_cast((v >> 8) & 0xFF)); + }; + auto putU32 = [&](std::uint32_t v) { + out.push_back(static_cast(v & 0xFF)); + out.push_back(static_cast((v >> 8) & 0xFF)); + out.push_back(static_cast((v >> 16) & 0xFF)); + out.push_back(static_cast((v >> 24) & 0xFF)); + }; + auto putTag = [&](const char* t) { + for (int i = 0; i < 4; ++i) + out.push_back(static_cast(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(nch)); + putU32(rate); + putU32(rate * static_cast(nch) * 4u); // avgBytesPerSec + putU16(static_cast(nch * 4)); // blockAlign + putU16(32u); // bitsPerSample + + // data chunk + putTag("data"); + putU32(static_cast(dataBytesCount)); + for (std::size_t i = 0; i < sampleCount && i < interleaved.size(); ++i) + putF32(static_cast(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(data[i]); + h *= kFnvPrime; + } + return fnvHex(h); +} + +std::string hashWavContent(const std::vector& 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(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('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 diff --git a/src/core/capture/wav_codec.h b/src/core/capture/wav_codec.h new file mode 100644 index 0000000..e85aa75 --- /dev/null +++ b/src/core/capture/wav_codec.h @@ -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 +#include +#include +#include + +#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(channelCount) * 4u; + return bytesPerFrame ? dataByteLength / bytesPerFrame : 0; + } +}; + +// Parses a WAV byte buffer's header geometry. Returns {valid=false} for anything +// that is not a canonical 32-bit-float RIFF/WAVE with a `fmt ` and a `data` chunk, +// or whose declared `data` length runs past the buffer. Does NOT copy PCM — it only +// locates it (extractFloatFrames does the copy). Pure + total (no throw, no UB). +WavLayout parseWavLayout(const std::vector& bytes); + +// Copies `frameCount` interleaved float frames starting at `startFrame` out of the +// WAV's `data` region into a flat [f0c0,f0c1,...] buffer (the shape peaks consumes). +// Clamps to the frames the buffer actually holds — never reads past `data`. Returns +// empty for an invalid layout or an out-of-range start. The floats are read +// little-endian via std::memcpy (no aliasing UB); on a big-endian host they would +// need a byte-swap — flagged, not handled, because the target (Windows/macOS/Linux +// on x86/ARM-LE) is little-endian and REAPER writes LE WAV. +std::vector extractFloatFrames(const std::vector& bytes, + const WavLayout& layout, + std::size_t startFrame, + std::size_t frameCount); + +// --- 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& 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 buildFloat32Wav(int nch, std::uint32_t rate, + std::size_t frameCount, + const std::vector& 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& bytes); + +} // namespace reasampler::capture diff --git a/src/core/capture/wav_trim.cpp b/src/core/capture/wav_trim.cpp deleted file mode 100644 index 3fbe945..0000000 --- a/src/core/capture/wav_trim.cpp +++ /dev/null @@ -1,160 +0,0 @@ -// wav_trim — pure implementation. See wav_trim.h. NO REAPER / SWELL / vendor. - -#include "core/capture/wav_trim.h" - -#include // 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& b, std::size_t off) { - return static_cast(b[off] | (b[off + 1] << 8)); -} -std::uint32_t readU32LE(const std::vector& b, std::size_t off) { - return static_cast(b[off]) | - (static_cast(b[off + 1]) << 8) | - (static_cast(b[off + 2]) << 16) | - (static_cast(b[off + 3]) << 24); -} - -bool tagEquals(const std::vector& b, std::size_t off, const char* tag) { - return off + 4 <= b.size() && std::memcmp(b.data() + off, tag, 4) == 0; -} - -// WAVE format tags we accept as 32-bit float (see wav_trim.h FORMAT ASSUMPTION). -constexpr std::uint16_t kWaveFormatIeeeFloat = 0x0003; -constexpr std::uint16_t kWaveFormatExtensible = 0xFFFE; - -} // namespace - -WavLayout parseWavLayout(const std::vector& bytes) { - WavLayout out; - - // Minimum viable RIFF/WAVE: "RIFF"(4) size(4) "WAVE"(4) = 12 bytes. - if (bytes.size() < 12) return out; - if (!tagEquals(bytes, 0, "RIFF")) return out; - if (!tagEquals(bytes, 8, "WAVE")) return out; - - bool haveFmt = false; - std::uint16_t fmtTag = 0, channels = 0, bitsPerSample = 0; - std::uint32_t sampleRate = 0; - std::uint16_t extensibleSubFormatTag = 0; // set only when fmtTag == kWaveFormatExtensible - - // Walk the sub-chunks after "WAVE" (offset 12). Each is: id(4) size(4) body(size), - // body padded to an even byte count (RIFF word alignment). Stop cleanly if a - // header would run past the buffer — a malformed/truncated file is "invalid", - // never an OOB read. - std::size_t pos = 12; - while (pos + 8 <= bytes.size()) { - const std::size_t bodyOffset = pos + 8; - const std::uint32_t bodySize = readU32LE(bytes, pos + 4); - - if (tagEquals(bytes, pos, "fmt ")) { - // fmt body: at least 16 bytes (PCM/float common fields). - if (bodyOffset + 16 > bytes.size() || bodySize < 16) return out; - fmtTag = readU16LE(bytes, bodyOffset + 0); - channels = readU16LE(bytes, bodyOffset + 2); - sampleRate = readU32LE(bytes, bodyOffset + 4); - bitsPerSample = readU16LE(bytes, bodyOffset + 14); - // For WAVE_FORMAT_EXTENSIBLE (0xFFFE), read the SubFormat GUID's leading - // 2-byte tag at body offset 24 to distinguish float (0x0003) from PCM - // integer (0x0001) and all other sub-formats. Body must be >= 40 bytes to - // reach GUID offset 24 + 16 bytes of GUID, and the full GUID must fit in - // the buffer; otherwise we leave extensibleSubFormatTag at 0 (rejected). - if (fmtTag == kWaveFormatExtensible) { - if (bodySize >= 40 && bodyOffset + 40 <= bytes.size()) { - extensibleSubFormatTag = readU16LE(bytes, bodyOffset + 24); - } - } - haveFmt = true; - } else if (tagEquals(bytes, pos, "data")) { - // The data chunk: PCM starts at bodyOffset, declared length bodySize. - // Reject if it runs past the buffer (truncated / lying header). - if (bodyOffset + bodySize > bytes.size()) return out; - if (!haveFmt) return out; // data before fmt — not a WAV we parse - - // Plain IEEE-float tag (0x0003): accept as-is. - // Extensible tag (0xFFFE): accept only when the SubFormat tag read from - // the GUID at body offset 24 is also 0x0003 (IEEE float). SubFormat tag - // 0x0001 (PCM integer) or anything else with bitsPerSample==32 is NOT - // float and must be rejected to prevent mis-decoding as float. - const bool floatTag = (fmtTag == kWaveFormatIeeeFloat) || - (fmtTag == kWaveFormatExtensible && - extensibleSubFormatTag == kWaveFormatIeeeFloat); - if (!floatTag || bitsPerSample != 32 || channels == 0) return out; - - out.valid = true; - out.channelCount = channels; - out.sampleRate = sampleRate; - out.dataByteOffset = bodyOffset; - out.dataByteLength = bodySize; - out.riffSizeFieldOffset = 4; - out.dataSizeFieldOffset = pos + 4; // the `data` size field (LE uint32) - return out; - } - - // Advance past this chunk's body, honoring RIFF even-byte padding. Guard the - // additions against size_t overflow (a hostile bodySize near SIZE_MAX). - std::size_t advance = bodySize; - if (advance & 1u) ++advance; // pad byte - if (advance > bytes.size() - bodyOffset) break; // would overrun -> stop - pos = bodyOffset + advance; - } - - return out; // no data chunk found -> invalid -} - -std::vector extractFloatFrames(const std::vector& bytes, - const WavLayout& layout, - std::size_t startFrame, - std::size_t frameCount) { - std::vector out; - if (!layout.valid) return out; - - const std::size_t bytesPerFrame = - static_cast(layout.channelCount) * 4u; - const std::size_t totalFrames = layout.frameCount(); - if (startFrame >= totalFrames) return out; - - // Clamp the requested span to the frames that actually exist. - const std::size_t avail = totalFrames - startFrame; - const std::size_t frames = (frameCount < avail) ? frameCount : avail; - if (frames == 0) return out; - - const std::size_t firstByte = - layout.dataByteOffset + startFrame * bytesPerFrame; - out.resize(frames * layout.channelCount); - // memcpy each float (LE on target hosts — see header's byte-order note). - for (std::size_t i = 0; i < out.size(); ++i) { - float f = 0.0f; - std::memcpy(&f, bytes.data() + firstByte + i * 4u, 4u); - out[i] = f; - } - return out; -} - -WavTruncatePlan planWavTruncate(const WavLayout& layout, std::size_t keptFrames) { - WavTruncatePlan plan; - if (!layout.valid) return plan; - - const std::size_t totalFrames = layout.frameCount(); - if (keptFrames > totalFrames) return plan; // never grow - - const std::size_t bytesPerFrame = - static_cast(layout.channelCount) * 4u; - const std::size_t keptDataBytes = keptFrames * bytesPerFrame; - - plan.valid = true; - plan.newFileByteLength = layout.dataByteOffset + keptDataBytes; - plan.dataSizeFieldOffset = layout.dataSizeFieldOffset; - plan.newDataSize = static_cast(keptDataBytes); - plan.riffSizeFieldOffset = layout.riffSizeFieldOffset; - // RIFF size counts everything after the 8-byte "RIFF"+size prefix. - plan.newRiffSize = static_cast(plan.newFileByteLength - 8); - return plan; -} - -} // namespace reasampler::capture diff --git a/src/core/capture/wav_trim.h b/src/core/capture/wav_trim.h index 497790d..e2d182f 100644 --- a/src/core/capture/wav_trim.h +++ b/src/core/capture/wav_trim.h @@ -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 -#include -#include - -#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(channelCount) * 4u; - return bytesPerFrame ? dataByteLength / bytesPerFrame : 0; - } -}; - -// Parses a WAV byte buffer's header geometry. Returns {valid=false} for anything -// that is not a canonical 32-bit-float RIFF/WAVE with a `fmt ` and a `data` chunk, -// or whose declared `data` length runs past the buffer. Does NOT copy PCM — it only -// locates it (extractFloatFrames does the copy). Pure + total (no throw, no UB). -WavLayout parseWavLayout(const std::vector& bytes); - -// Copies `frameCount` interleaved float frames starting at `startFrame` out of the -// WAV's `data` region into a flat [f0c0,f0c1,...] buffer (the shape peaks consumes). -// Clamps to the frames the buffer actually holds — never reads past `data`. Returns -// empty for an invalid layout or an out-of-range start. The floats are read -// little-endian via std::memcpy (no aliasing UB); on a big-endian host they would -// need a byte-swap — flagged, not handled, because the target (Windows/macOS/Linux -// on x86/ARM-LE) is little-endian and REAPER writes LE WAV. -std::vector extractFloatFrames(const std::vector& bytes, - const WavLayout& layout, - std::size_t startFrame, - std::size_t frameCount); - -// The plan to truncate a parsed WAV to `keptFrames` frames: the new total file byte -// length and the two size-field values to patch. `valid` is false if the layout is -// invalid or keptFrames exceeds the file's frames (never GROW a file — the caller -// clamps beforehand; this guards it too). -struct WavTruncatePlan { - bool valid = false; - - std::size_t newFileByteLength = 0; // truncate the file to exactly this length - std::size_t dataSizeFieldOffset = 0; // where to write newDataSize (LE uint32) - std::uint32_t newDataSize = 0; // kept PCM byte length - std::size_t riffSizeFieldOffset = 4; // where to write newRiffSize (LE uint32) - std::uint32_t newRiffSize = 0; // newFileByteLength - 8 (RIFF size excludes - // the 8-byte "RIFF"+size prefix) -}; - -// Computes the truncate plan to keep exactly `keptFrames` frames of a parsed WAV. -// keptFrames == layout.frameCount() is a valid no-op plan (file unchanged). Pure + -// total. The shell applies it: patch the two size fields in the byte buffer, then -// truncate the file to newFileByteLength. -WavTruncatePlan planWavTruncate(const WavLayout& layout, std::size_t keptFrames); - -} // namespace reasampler::capture +#include "core/capture/wav_codec.h" diff --git a/src/ingest.cpp b/src/ingest.cpp index 6496abc..bfc6d80 100644 --- a/src/ingest.cpp +++ b/src/ingest.cpp @@ -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& 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 buildFloat32Wav(int nch, std::uint32_t rate, - std::size_t frameCount, - const std::vector& interleaved) { - const std::size_t sampleCount = frameCount * static_cast(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(36u + dataBytesCount); // 4("WAVE")+24(fmt chunk)+8(data hdr)+data - - std::vector out; - out.reserve(44u + dataBytesCount); - - auto putU16 = [&](std::uint16_t v) { - out.push_back(static_cast(v & 0xFF)); - out.push_back(static_cast((v >> 8) & 0xFF)); - }; - auto putU32 = [&](std::uint32_t v) { - out.push_back(static_cast(v & 0xFF)); - out.push_back(static_cast((v >> 8) & 0xFF)); - out.push_back(static_cast((v >> 16) & 0xFF)); - out.push_back(static_cast((v >> 24) & 0xFF)); - }; - auto putTag = [&](const char* t) { - for (int i = 0; i < 4; ++i) - out.push_back(static_cast(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(nch)); - putU32(rate); - putU32(rate * static_cast(nch) * 4u); // avgBytesPerSec - putU16(static_cast(nch * 4)); // blockAlign - putU16(32u); // bitsPerSample - - // data chunk - putTag("data"); - putU32(static_cast(dataBytesCount)); - for (std::size_t i = 0; i < sampleCount && i < interleaved.size(); ++i) - putF32(static_cast(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). diff --git a/src/shell/capture/capture.cpp b/src/shell/capture/capture.cpp index 0269c07..377f74a 100644 --- a/src/shell/capture/capture.cpp +++ b/src/shell/capture/capture.cpp @@ -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 #include #include #include @@ -44,6 +45,7 @@ #include #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(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 counter{0}; + const std::time_t now = std::time(nullptr); + return prefix + std::to_string(static_cast(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(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 fileBytes = util::readFileBytes(absolutePath); + if (!fileBytes.empty()) { + s.contentHash = hashWavContent(fileBytes); + } + } + + s.createdTimestamp = static_cast(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 fileBytes = readFileBytes(expectedPath); - if (!fileBytes.empty()) { - s.contentHash = hashWavContent(fileBytes); - } - } - s.createdTimestamp = static_cast(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 diff --git a/src/shell/capture/capture.h b/src/shell/capture/capture.h index fd9a54d..6fbb964 100644 --- a/src/shell/capture/capture.h +++ b/src/shell/capture/capture.h @@ -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 @@ -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: "-" where 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 diff --git a/src/shell/capture/capture_batch.cpp b/src/shell/capture/capture_batch.cpp new file mode 100644 index 0000000..9893817 --- /dev/null +++ b/src/shell/capture/capture_batch.cpp @@ -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 +#include +#include +#include +#include + +#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 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> collectRazorAreas() +{ + std::vector> areas; + const int n = CountTracks(nullptr); + for (int i = 0; i < n; ++i) + { + MediaTrack* tr = GetTrack(nullptr, i); + if (!tr) continue; + std::vector 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 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 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 ranges; + ranges.reserve(itemUnits.size()); + for (const ItemUnit& u : itemUnits) + ranges.push_back({u.start, u.end}); + const std::vector 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> areas = collectRazorAreas(); + if (areas.empty()) + { + ShowConsoleMsg("ReaSampler batch capture: make at least one razor area first.\n"); + return; + } + + std::vector ranges; + ranges.reserve(areas.size()); + for (const auto& a : areas) + ranges.push_back({a.second.startSeconds, a.second.endSeconds}); + const std::vector 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 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 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 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(recipe->sourceMode); + req.startSeconds = recipe->startSeconds; + req.endSeconds = recipe->endSeconds; + req.wetDry = 1.0; + req.tailMode = static_cast(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 items; + items.reserve(static_cast(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 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 diff --git a/src/shell/capture/capture_batch.h b/src/shell/capture/capture_batch.h new file mode 100644 index 0000000..f161421 --- /dev/null +++ b/src/shell/capture/capture_batch.h @@ -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 diff --git a/src/shell/capture/capture_orchestrator.cpp b/src/shell/capture/capture_orchestrator.cpp new file mode 100644 index 0000000..c735bab --- /dev/null +++ b/src/shell/capture/capture_orchestrator.cpp @@ -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 +#include + +#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& 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 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& 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 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 diff --git a/src/shell/capture/capture_orchestrator.h b/src/shell/capture/capture_orchestrator.h new file mode 100644 index 0000000..d5d9b3b --- /dev/null +++ b/src/shell/capture/capture_orchestrator.h @@ -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 + +#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& 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 diff --git a/src/shell/capture/capture_realtime_finalize.cpp b/src/shell/capture/capture_realtime_finalize.cpp new file mode 100644 index 0000000..c2a5e9f --- /dev/null +++ b/src/shell/capture/capture_realtime_finalize.cpp @@ -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 +#include +#include +#include +#include + +#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 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( + rangeSeconds * static_cast(layout.sampleRate) + 0.5); + if (rangeEndFrame > totalFrames) rangeEndFrame = totalFrames; + + // Nothing recorded past the range end (the tail window was empty) -> nothing to + // trim; keep as-is. (Shouldn't happen for Auto, but total by construction.) + if (rangeEndFrame >= totalFrames) return kNoTrim; + + // Scan ONLY the tail region (frames after the original range end). The trim never + // eats into the range body — the scan starts at rangeEndFrame. + const std::size_t tailFrames = totalFrames - rangeEndFrame; + const std::vector tailPcm = + extractFloatFrames(bytes, layout, rangeEndFrame, tailFrames); + if (tailPcm.empty()) return kNoTrim; + + const float threshold = static_cast(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(bytes.data()), + static_cast(plan.newFileByteLength)); + if (!out) return kNoTrim; + out.close(); + + // The trimmed length in seconds for the Sample metadata. + return static_cast(keptFrames) / static_cast(layout.sampleRate); +} + +} // 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 buf(4096, '\0'); + GetMediaSourceFileName(src, buf.data(), static_cast(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 diff --git a/src/shell/capture/capture_realtime_finalize.h b/src/shell/capture/capture_realtime_finalize.h new file mode 100644 index 0000000..e1a900f --- /dev/null +++ b/src/shell/capture/capture_realtime_finalize.h @@ -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 + +#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 diff --git a/src/shell/capture/capture_realtime.cpp b/src/shell/capture/capture_realtime_shell.cpp similarity index 67% rename from src/shell/capture/capture_realtime.cpp rename to src/shell/capture/capture_realtime_shell.cpp index 21c0612..981e76d 100644 --- a/src/shell/capture/capture_realtime.cpp +++ b/src/shell/capture/capture_realtime_shell.cpp @@ -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 #include -#include -#include #include -#include #include #include -#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(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 buf(4096, '\0'); - GetMediaSourceFileName(src, buf.data(), static_cast(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& bytes, std::size_t off, std::uint32_t v) { - bytes[off + 0] = static_cast(v & 0xFF); - bytes[off + 1] = static_cast((v >> 8) & 0xFF); - bytes[off + 2] = static_cast((v >> 16) & 0xFF); - bytes[off + 3] = static_cast((v >> 24) & 0xFF); -} - -// ============================================================================ -// §TAIL — Auto-mode PCM decay-scan trim (docs/product/capture-tail.md §realtime) -// ============================================================================ -// After the recorded file is stable and moved into the bank (the file we OWN — never -// the project), Auto mode trims the trailing decay: read the WAV, scan the tail -// region (frames AFTER the original range end) backward for the last frame above -// -72 dB, and truncate the file there. Rules (spec): -// * no frame in the tail window above -72 dB -> trim back to the original range end -// * signal never falls below -72 dB in window -> keep the full window (cap did its job) -// * otherwise -> trim one frame past the last audible -// -// Returns the trimmed length in SECONDS (for the Sample), or a negative value to -// signal "no trim applied" (caller keeps the pre-trim length). Best-effort and -// non-fatal: any unreadable/unknown/short file skips the trim (keeps the full window) -// rather than risk corrupting the capture — realtime tail is a convenience path. -// -// FORMAT / FLUSH ASSUMPTIONS (DAW-verify): the recorded file is a canonical 32-bit -// float WAV (REAPER project record format — the manual procedure sets it) and is fully -// flushed/closed before this runs (the tick() Finalizing size-stable wait guarantees -// that for the normal path; abort()'s best-effort finalize races it, documented). -double trimAutoTailInPlace(const std::string& path, - double rangeStartSeconds, - double rangeEndSeconds) { - constexpr double kNoTrim = -1.0; - - std::vector bytes = 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( - rangeSeconds * static_cast(layout.sampleRate) + 0.5); - if (rangeEndFrame > totalFrames) rangeEndFrame = totalFrames; - - // Nothing recorded past the range end (the tail window was empty) -> nothing to - // trim; keep as-is. (Shouldn't happen for Auto, but total by construction.) - if (rangeEndFrame >= totalFrames) return kNoTrim; - - // Scan ONLY the tail region (frames after the original range end). The trim never - // eats into the range body — the scan starts at rangeEndFrame. - const std::size_t tailFrames = totalFrames - rangeEndFrame; - const std::vector tailPcm = - extractFloatFrames(bytes, layout, rangeEndFrame, tailFrames); - if (tailPcm.empty()) return kNoTrim; - - const float threshold = static_cast(reasampler::autoTrimEndRatio()); - const std::size_t lastAbove = reasampler::lastFrameAboveThreshold( - tailPcm, layout.channelCount, tailFrames, threshold); - - // keptFrames: the total frame count the trimmed file retains. - // no audible tail frame -> trim back to the range end (rangeEndFrame frames) - // an audible frame at idx -> keep range body + up to and including that frame - // The "signal never falls below threshold" case falls out naturally: lastAbove is - // the final tail frame, so keptFrames == totalFrames (the full window is kept). - std::size_t keptFrames; - if (lastAbove == reasampler::kNoFrameAboveThreshold) { - keptFrames = rangeEndFrame; - } else { - keptFrames = rangeEndFrame + (lastAbove + 1); - } - if (keptFrames >= totalFrames) return kNoTrim; // full window kept -> no truncate - - const reasampler::WavTruncatePlan plan = planWavTruncate(layout, keptFrames); - if (!plan.valid) return kNoTrim; - - // Patch the RIFF + data size fields in the in-memory buffer so they describe the - // kept frame count, then rewrite the file as exactly the first newFileByteLength - // bytes (header + patched sizes + retained PCM). A single truncating write is the - // simplest correct truncate — no separate resize step, no partial-write window - // where the on-disk sizes and length disagree. The result is a valid, playable WAV - // of the kept frames (verified by the wav_trim re-parse test). - writeU32LE(bytes, plan.dataSizeFieldOffset, plan.newDataSize); - writeU32LE(bytes, plan.riffSizeFieldOffset, plan.newRiffSize); - - // NOTE (DAW-verify, best-effort): a truncating write that fails MID-write (a full - // disk, a yanked drive) would leave a short file while we return kNoTrim, so the - // Sample length would overstate the file. Vanishingly unlikely for a just-recorded - // local bank file, and realtime tail is a convenience path, so a temp-file+atomic- - // rename is not warranted here; flagged rather than built. - std::ofstream out(path, std::ios::binary | std::ios::trunc); - if (!out) return kNoTrim; // could not reopen to rewrite — leave the full file - out.write(reinterpret_cast(bytes.data()), - static_cast(plan.newFileByteLength)); - if (!out) return kNoTrim; - out.close(); - - // The trimmed length in seconds for the Sample metadata. - return static_cast(keptFrames) / static_cast(layout.sampleRate); -} - -// Builds a CaptureResult for a finalized recording: discover the recorded file, -// move it into the bank, populate the Sample via the pure mapping. Returns Ok + -// Sample on success, or a RenderFailed result. Does NOT restore — the caller -// 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(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::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 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 diff --git a/src/shell/capture/realtime_lifecycle.cpp b/src/shell/capture/realtime_lifecycle.cpp new file mode 100644 index 0000000..275694c --- /dev/null +++ b/src/shell/capture/realtime_lifecycle.cpp @@ -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 diff --git a/src/shell/capture/realtime_lifecycle.h b/src/shell/capture/realtime_lifecycle.h new file mode 100644 index 0000000..ac71be5 --- /dev/null +++ b/src/shell/capture/realtime_lifecycle.h @@ -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 diff --git a/src/shell/capture/scope_resolve.cpp b/src/shell/capture/scope_resolve.cpp new file mode 100644 index 0000000..6caabe5 --- /dev/null +++ b/src/shell/capture/scope_resolve.cpp @@ -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 // project-dir derivation for provenance parent resolution +#include + +#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 allRanges; + const int n = CountTracks(nullptr); + for (int i = 0; i < n; ++i) + { + MediaTrack* tr = GetTrack(nullptr, i); + if (!tr) continue; + std::vector buf(8192, '\0'); + if (!GetSetMediaTrackInfo_String(tr, "P_RAZOREDITS", buf.data(), false)) + continue; + std::vector 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 buf(4096, '\0'); + EnumProjects(-1, buf.data(), static_cast(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 buildCaptureProvenance( + const BankBook& book, const CaptureRequest& req, + CaptureScope scope, const ResolvedSource& src) +{ + const std::string projectDir = currentProjectDir(); + const std::vector 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 sourceFiles = + scope == CaptureScope::Item + ? selectedItemSourceFiles() + : trackItemSourceFiles(src.sourceTracks, req.startSeconds, + req.endSeconds); + + const std::optional 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(req.sourceMode); + recipe.startSeconds = req.startSeconds; + recipe.endSeconds = req.endSeconds; + recipe.tailMode = static_cast(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 items; + items.reserve(static_cast(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 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 diff --git a/src/shell/capture/scope_resolve.h b/src/shell/capture/scope_resolve.h new file mode 100644 index 0000000..991b614 --- /dev/null +++ b/src/shell/capture/scope_resolve.h @@ -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 +#include +#include + +#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 sourceTracks; // item-owning tracks / selected tracks + std::vector 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 buildCaptureProvenance( + const BankBook& book, const CaptureRequest& req, + CaptureScope scope, const ResolvedSource& src); + +} // namespace reasampler::capture diff --git a/tests/test_capture_paths.cpp b/tests/test_capture_paths.cpp index 3d253c9..3b1b95c 100644 --- a/tests/test_capture_paths.cpp +++ b/tests/test_capture_paths.cpp @@ -5,11 +5,8 @@ #include "../src/core/capture/capture_paths.h" -#include #include -#include // std::memcpy (for putF32cp in hashWavContent tests) #include -#include 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& b, std::uint16_t v) { - b.push_back(static_cast(v & 0xFF)); - b.push_back(static_cast((v >> 8) & 0xFF)); -} -static void putU32cp(std::vector& b, std::uint32_t v) { - b.push_back(static_cast(v & 0xFF)); - b.push_back(static_cast((v >> 8) & 0xFF)); - b.push_back(static_cast((v >> 16) & 0xFF)); - b.push_back(static_cast((v >> 24) & 0xFF)); -} -static void putTagcp(std::vector& b, const char* t) { - for (int i = 0; i < 4; ++i) b.push_back(static_cast(t[i])); -} -static void putF32cp(std::vector& b, float f) { - std::uint8_t tmp[4]; - std::memcpy(tmp, &f, 4); - for (int i = 0; i < 4; ++i) b.push_back(tmp[i]); -} - -// 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 buildTestWav( - std::uint16_t channels, std::uint32_t sampleRate, - const std::vector& samples, - bool insertMeta = false, - const char* metaTag = "bext", - const std::vector& metaBody = {}) { - - std::vector chunks; - - if (insertMeta && !metaBody.empty()) { - putTagcp(chunks, metaTag); - putU32cp(chunks, static_cast(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(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(channels * 4)); // blockAlign - putU16cp(chunks, 32); // bitsPerSample - - // data chunk. - putTagcp(chunks, "data"); - putU32cp(chunks, dataBytes); - for (float f : samples) putF32cp(chunks, f); - - std::vector wav; - putTagcp(wav, "RIFF"); - putU32cp(wav, static_cast(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 audio = {0.1f, -0.2f, 0.3f, -0.4f}; - std::vector metaA(64, 0x00); // bext body, all zeros (e.g. epoch) - std::vector 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 audioA = {0.5f, 0.5f}; - const std::vector 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 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 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 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 audio = {1.0f, -1.0f, 0.5f}; - std::vector 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 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(); diff --git a/tests/test_realtime_record.cpp b/tests/test_capture_realtime.cpp similarity index 97% rename from tests/test_realtime_record.cpp rename to tests/test_capture_realtime.cpp index 1da6e96..ca5b2f6 100644 --- a/tests/test_realtime_record.cpp +++ b/tests/test_capture_realtime.cpp @@ -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 #include @@ -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; } diff --git a/tests/test_wav_trim.cpp b/tests/test_wav_codec.cpp similarity index 55% rename from tests/test_wav_trim.cpp rename to tests/test_wav_codec.cpp index 10b651d..f3038e6 100644 --- a/tests/test_wav_trim.cpp +++ b/tests/test_wav_codec.cpp @@ -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 #include @@ -263,15 +267,10 @@ static void testTruncatePlanKeepFewer() { // Applying the plan yields a buffer that re-parses to exactly 4 frames. std::vector trimmed(wav.begin(), wav.begin() + p.newFileByteLength); - // Patch the two size fields (what the shell does before truncating on disk). - auto writeU32 = [](std::vector& b, std::size_t off, std::uint32_t v) { - b[off + 0] = static_cast(v & 0xFF); - b[off + 1] = static_cast((v >> 8) & 0xFF); - b[off + 2] = static_cast((v >> 16) & 0xFF); - b[off + 3] = static_cast((v >> 24) & 0xFF); - }; - writeU32(trimmed, p.dataSizeFieldOffset, p.newDataSize); - writeU32(trimmed, p.riffSizeFieldOffset, p.newRiffSize); + // 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 trimmed(wav.begin(), wav.begin() + p.newFileByteLength); - auto writeU32 = [](std::vector& b, std::size_t off, std::uint32_t v) { - b[off + 0] = static_cast(v & 0xFF); - b[off + 1] = static_cast((v >> 8) & 0xFF); - b[off + 2] = static_cast((v >> 16) & 0xFF); - b[off + 3] = static_cast((v >> 24) & 0xFF); - }; - writeU32(trimmed, p.dataSizeFieldOffset, p.newDataSize); - writeU32(trimmed, p.riffSizeFieldOffset, p.newRiffSize); + 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 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 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(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 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 buildMetaWav( + std::uint16_t channels, std::uint32_t sampleRate, + const std::vector& samples, + bool insertMeta = false, + const char* metaTag = "bext", + const std::vector& metaBody = {}) { + + std::vector chunks; + + if (insertMeta && !metaBody.empty()) { + putTag(chunks, metaTag); + putU32(chunks, static_cast(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(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(channels * 4)); // blockAlign + putU16(chunks, 32); // bitsPerSample + + // data chunk. + putTag(chunks, "data"); + putU32(chunks, dataBytes); + for (float f : samples) putFloat(chunks, f); + + std::vector wav; + putTag(wav, "RIFF"); + putU32(wav, static_cast(4 + chunks.size())); + putTag(wav, "WAVE"); + wav.insert(wav.end(), chunks.begin(), chunks.end()); + return wav; +} + +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 audio = {0.1f, -0.2f, 0.3f, -0.4f}; + std::vector metaA(64, 0x00); // bext body, all zeros (e.g. epoch) + std::vector 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 audioA = {0.5f, 0.5f}; + const std::vector 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 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 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 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 audio = {1.0f, -1.0f, 0.5f}; + std::vector 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 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 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(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; }