diff --git a/CMakeLists.txt b/CMakeLists.txt index 8e3063f..2928690 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -178,6 +178,18 @@ add_library(render_settings STATIC src/render_settings.cpp) target_include_directories(render_settings PUBLIC src) target_link_libraries(render_settings PUBLIC bank_model) +# --------------------------------------------------------------------------- +# 2f'') Pure batch_capture library — NO REAPER, NO SWELL. The M11 batch-capture +# logic: an ordered list of source ranges (one per selected item / per razor +# area) -> validated, ordinal-assigned capture units (empty/inverted dropped), +# plus order-preserving per-unit result aggregation into a mixed-result summary +# line. Split out so the plan/aggregate/summary is unit-tested outside the DAW; +# the selection read, transient re-selection, and render loop stay in main.cpp. +# No dependency on bank_model — it takes plain ranges/values at its boundary. +# --------------------------------------------------------------------------- +add_library(batch_capture STATIC src/batch_capture.cpp) +target_include_directories(batch_capture PUBLIC src) + # --------------------------------------------------------------------------- # 2f') Pure tail_control library — NO REAPER, NO SWELL. The docked bank_panel's # tail-mode toggle logic (T1 exposure): TailSetting state, cycle order @@ -341,6 +353,10 @@ add_executable(render_settings_tests tests/test_render_settings.cpp) target_link_libraries(render_settings_tests PRIVATE render_settings) add_test(NAME render_settings_tests COMMAND render_settings_tests) +add_executable(batch_capture_tests tests/test_batch_capture.cpp) +target_link_libraries(batch_capture_tests PRIVATE batch_capture) +add_test(NAME batch_capture_tests COMMAND batch_capture_tests) + 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) @@ -417,7 +433,7 @@ add_library(reaper_reasampler MODULE src/bank_book.cpp src/owned_manifest.cpp ) -target_link_libraries(reaper_reasampler PRIVATE bank_model capture_paths peaks bank_grid mode_switch tab_strip view_mode_model insert_plan render_settings tail_control realtime_record bank_book wav_trim owned_manifest prune_reconcile prune_button app_version provenance) +target_link_libraries(reaper_reasampler PRIVATE 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) 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/src/batch_capture.cpp b/src/batch_capture.cpp new file mode 100644 index 0000000..3e46851 --- /dev/null +++ b/src/batch_capture.cpp @@ -0,0 +1,76 @@ +// batch_capture.cpp — pure logic for M11 batch capture. See header. +// NO REAPER types; unit-tested by tests/test_batch_capture.cpp. + +#include "batch_capture.h" + +#include + +namespace reasampler { + +std::vector planCaptureUnits(const std::vector& ranges) { + std::vector units; + units.reserve(ranges.size()); + int ordinal = 0; + for (const BatchRange& r : ranges) { + // Drop empty/inverted ranges — the offline backend refuses end<=start too, so + // planning one would only manufacture a guaranteed per-unit failure. Ordinals + // count kept units so the reported numbering is contiguous. + if (!(r.endSeconds > r.startSeconds)) continue; + ++ordinal; + units.push_back({ordinal, r.startSeconds, r.endSeconds}); + } + return units; +} + +void BatchOutcome::record(int ordinal, bool ok, std::string detail) { + results_.push_back({ordinal, ok, std::move(detail)}); +} + +std::size_t BatchOutcome::succeeded() const { + return static_cast( + std::count_if(results_.begin(), results_.end(), + [](const BatchUnitResult& r) { return r.ok; })); +} + +std::size_t BatchOutcome::failed() const { + return results_.size() - succeeded(); +} + +std::vector BatchOutcome::failures() const { + std::vector out; + for (const BatchUnitResult& r : results_) + if (!r.ok) out.push_back(r); + return out; +} + +std::string BatchOutcome::summaryLine(const std::string& noun) const { + const std::size_t n = total(); + const std::size_t ok = succeeded(); + + if (n == 0) + return "ReaSampler batch capture: nothing to capture."; + + const std::string plural = (n == 1) ? noun : noun + "s"; + + if (ok == n) + return "ReaSampler batch capture: " + std::to_string(ok) + " " + + plural + " captured."; + + // Mixed / all-failed: report the ratio and enumerate the failed ordinals so the + // user knows exactly which units to retry. No partial corruption is implied — + // each captured unit is a complete, independent bank sample. + std::string line = "ReaSampler batch capture: " + std::to_string(ok) + " of " + + std::to_string(n) + " " + plural + " captured (" + + std::to_string(n - ok) + " failed: "; + bool first = true; + for (const BatchUnitResult& f : results_) { + if (f.ok) continue; + if (!first) line += ", "; + line += "#" + std::to_string(f.ordinal); + first = false; + } + line += ")."; + return line; +} + +} // namespace reasampler diff --git a/src/batch_capture.h b/src/batch_capture.h new file mode 100644 index 0000000..213eb9e --- /dev/null +++ b/src/batch_capture.h @@ -0,0 +1,100 @@ +#pragma once +// batch_capture — the REAPER-free logic behind M11 batch capture (one action fires +// N captures: one bank sample per selected item / per razor area). +// +// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO +// vendor/ includes. Standard library only. The batch shell (main.cpp) reads the DAW +// state (selected items -> their exact bounds; every track's P_RAZOREDITS -> areas) +// and hands the raw ranges here so the genuinely-pure, easy-to-get-wrong pieces are +// unit-tested outside the DAW: +// +// 1. planCaptureUnits: an ordered list of (start,end) source ranges -> an ordered +// list of CaptureUnit, each carrying its 1-based ordinal and validated bounds. +// Empty/inverted ranges are DROPPED (mirrors the offline backend's own +// end>start guard) so a zero-length item/area never produces a stray render. +// Order is preserved: unit ordinals count only the KEPT units, so a batch of +// three valid items yields ordinals 1,2,3 regardless of dropped neighbors. +// 2. BatchOutcome: order-preserving aggregation of per-unit results into a summary +// (succeeded / failed counts + the ordered list of failures) so the shell can +// report a mixed result with one console line and no partial-corruption +// ambiguity. The AGGREGATION is pure; the render loop that feeds it is shell. +// +// Range is the ONLY thing that varies per unit here. FX scope (item vs track) is a +// per-ACTION constant the shell already owns (fxBypassPlanFor); it is not a +// per-unit field. Item-batch uses item scope; razor-batch uses track scope — the +// shell passes the scope straight through to each render, unchanged from the +// single-capture path. + +#include +#include +#include + +namespace reasampler { + +// One capture in a batch: an exact source range plus its 1-based ordinal within the +// KEPT set. The ordinal disambiguates per-unit file stems (the offline backend's +// unique tag is 1-second-granular, so a fast batch could otherwise collide N files +// onto one name) and labels a failure in the summary. +struct CaptureUnit { + int ordinal = 0; // 1-based, counts kept units only + double startSeconds = 0.0; // exact — no rounding + double endSeconds = 0.0; +}; + +// A source range handed in by the shell (a selected item's [pos, pos+len] or one +// razor area's [start, end]). Kept as a distinct type from CaptureUnit so the input +// (raw, possibly-invalid) and the output (validated, ordinal-assigned) do not share +// a shape by accident. Named BatchRange (not SourceRange) to avoid collision with +// bank_model's SourceRange, which carries PPQ fields this planner does not need. +struct BatchRange { + double startSeconds = 0.0; + double endSeconds = 0.0; +}; + +// Validates + orders a batch's source ranges into capture units. Preserves input +// order; DROPS every range with end <= start (empty/inverted) so no stray render is +// planned; assigns 1-based ordinals over the KEPT units. An empty input (no selected +// item / no razor area) yields an empty plan — the shell reports "nothing to batch" +// and writes nothing (the same no-op posture the single-capture path takes). +std::vector planCaptureUnits(const std::vector& ranges); + +// The per-unit verdict the shell records after each render attempt, in unit order. +struct BatchUnitResult { + int ordinal = 0; // the CaptureUnit's ordinal this result is for + bool ok = false; // true iff the render + bank-add succeeded + std::string detail; // failure reason (empty on success) — for the summary +}; + +// Order-preserving aggregation of a batch's per-unit results. Built incrementally by +// the shell (record() after each unit) so a mid-batch failure is captured without +// aborting the remaining units (no partial corruption: each unit is independent, and +// the selection is restored on every exit path by the shell's RAII guard). +class BatchOutcome { +public: + // Records one unit's verdict. Order of calls IS the reported order. + void record(int ordinal, bool ok, std::string detail = {}); + + std::size_t total() const { return results_.size(); } + std::size_t succeeded() const; + std::size_t failed() const; + + const std::vector& results() const { return results_; } + + // The ordered subset of results that failed (ok == false). For the summary line. + std::vector failures() const; + + // A single human summary line for the console (explicit-action response — allowed + // by the console policy; a batch-completion summary with failure counts qualifies, + // per-unit success spam does not). `noun` is the unit word ("item" / "razor area"). + // Examples: + // all-success, 3 items : "ReaSampler batch capture: 3 items captured." + // partial, 3 of 5 : "ReaSampler batch capture: 3 of 5 items captured " + // "(2 failed: #2, #4)." + // empty plan : "ReaSampler batch capture: nothing to capture." + std::string summaryLine(const std::string& noun) const; + +private: + std::vector results_; +}; + +} // namespace reasampler diff --git a/src/main.cpp b/src/main.cpp index 16c2793..ce5f399 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -18,9 +18,11 @@ #include "reaper_plugin.h" #include "reaper_plugin_functions.h" +#include #include #include #include +#include #include @@ -28,6 +30,7 @@ #include "app_version.h" #include "bank_model.h" #include "bank_panel.h" +#include "batch_capture.h" #include "capture.h" #include "insert.h" #include "persist.h" @@ -124,6 +127,14 @@ static int g_cmdToggleBankPanel = 0; static int g_cmdInsertSelected = 0; static int g_cmdInsertSelectedConform = 0; +// Command ids for the M11 batch-capture actions. NEW FOREVER-STABLE strings. One action +// fires N captures: CAPTURE_BATCH_ITEMS -> one bank sample per selected item (item scope); +// CAPTURE_BATCH_RAZOR -> one bank sample per razor area (track scope, each area's range). +// Each unit honors every precision invariant; the original selection is restored on every +// exit path. Bank-only, never places on the timeline (load-bearing principle). +static int g_cmdCaptureBatchItems = 0; +static int g_cmdCaptureBatchRazor = 0; + // Command id for the "capture selected track (realtime)" action. NEW FOREVER-STABLE // string. Records the selected track's OWN output in realtime (transport-driven) into // a hidden temp track via RealtimeRecordBackend, then moves the recorded file into the @@ -714,13 +725,75 @@ static reasampler::CaptureResult renderOffline( return backend.capture(req); } -// Runs one capture-action-table row: resolve its scope source + range, snapshot & -// clear the out-of-scope FX AND neutralize their fader gain + pan chain (RAII), -// render via the offline backend, add the Sample to the bank, 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. -// Non-destructive: FX-enable + fader gain + pan/width/law/mode are fully restored -// on every path by FxBypassGuard, and the backend restores every RENDER_* setting. +// 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. +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). + 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); + 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. static void RunCapture(const reasampler::CaptureActionDef& def) { ResolvedSource src; @@ -731,54 +804,14 @@ static void RunCapture(const reasampler::CaptureActionDef& def) return; } - // The tail mode is a PANEL SETTING (docked bank panel's toggle), not a per-action - // variant: the plain 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. tailMs is meaningful only for Manual and is pre-clamped by the panel. - const reasampler::TailSetting tail = reasampler::bankPanelTailSetting(); - - reasampler::CaptureRequest req; - req.sourceMode = reasampler::sourceModeForScope(def.scope); - req.startSeconds = src.startSeconds; // exact bounds — no rounding - req.endSeconds = src.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 = def.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, def.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 — see renderOffline - // and the FxBypassGuard header comment). Non-destructive; writes a file only. - reasampler::CaptureResult res = renderOffline(def.scope, src.sourceTracks, req); - + 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; } - // 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). - 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 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 later). - g_session.owned().add(res.sample.relativePath); // 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 @@ -786,6 +819,271 @@ static void RunCapture(const reasampler::CaptureActionDef& def) g_session.saveToActiveProject(); } +// --- 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. + if (anyAdded) + 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 + + if (anyAdded) + 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 @@ -1124,6 +1422,8 @@ static bool OnHookCommand(int command, int /*flag*/) if (command == g_cmdToggleBankPanel) { reasampler::bankPanelToggle(); 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; } @@ -1155,6 +1455,8 @@ static int OnToggleAction(int command) static gaccel_register_t g_accelToggleBankPanel{}; static gaccel_register_t g_accelInsertSelected{}; static gaccel_register_t g_accelInsertSelectedConform{}; +static gaccel_register_t g_accelCaptureBatchItems{}; +static gaccel_register_t g_accelCaptureBatchRazor{}; static gaccel_register_t g_accelCaptureTrackRealtime{}; static gaccel_register_t g_accelCancelRealtime{}; static gaccel_register_t g_accelRecaptureFromSource{}; @@ -1166,6 +1468,8 @@ static gaccel_register_t g_accelShowVersion{}; static std::string g_descToggleBankPanel; static std::string g_descInsertSelected; static std::string g_descInsertSelectedConform; +static std::string g_descCaptureBatchItems; +static std::string g_descCaptureBatchRazor; static std::string g_descCaptureTrackRealtime; static std::string g_descCancelRealtime; static std::string g_descRecaptureFromSource; @@ -1176,6 +1480,8 @@ static std::string g_descShowVersion; static const char* g_idToggleBankPanel = nullptr; static const char* g_idInsertSelected = nullptr; static const char* g_idInsertSelectedConform = nullptr; +static const char* g_idCaptureBatchItems = nullptr; +static const char* g_idCaptureBatchRazor = nullptr; static const char* g_idCaptureTrackRealtime = nullptr; static const char* g_idCancelRealtime = nullptr; static const char* g_idRecaptureFromSource = nullptr; @@ -1221,6 +1527,10 @@ extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT( g_rec->Register("-command_id", (void*)g_idCancelRealtime); g_rec->Register("-gaccel", (void*)&g_accelCaptureTrackRealtime); g_rec->Register("-command_id", (void*)g_idCaptureTrackRealtime); + g_rec->Register("-gaccel", (void*)&g_accelCaptureBatchRazor); + g_rec->Register("-command_id", (void*)g_idCaptureBatchRazor); + g_rec->Register("-gaccel", (void*)&g_accelCaptureBatchItems); + g_rec->Register("-command_id", (void*)g_idCaptureBatchItems); g_rec->Register("-gaccel", (void*)&g_accelInsertSelectedConform); g_rec->Register("-command_id", (void*)g_idInsertSelectedConform); g_rec->Register("-gaccel", (void*)&g_accelInsertSelected); @@ -1340,6 +1650,32 @@ extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT( rec->Register("gaccel", (void*)&g_accelInsertSelectedConform); } + // Register the M11 batch-capture actions (command_id -> gaccel -> hookcommand). Each + // fires N captures (one bank sample per selected item / per razor area), honoring every + // precision invariant per unit and restoring the original selection on every path. + // Channel-qualified FOREVER-STABLE ids. + g_idCaptureBatchItems = internCmdId("CAPTURE_BATCH_ITEMS"); + g_cmdCaptureBatchItems = rec->Register("command_id", (void*)g_idCaptureBatchItems); + if (g_cmdCaptureBatchItems) + { + g_descCaptureBatchItems = + reasampler::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); + } + + g_idCaptureBatchRazor = internCmdId("CAPTURE_BATCH_RAZOR"); + g_cmdCaptureBatchRazor = rec->Register("command_id", (void*)g_idCaptureBatchRazor); + if (g_cmdCaptureBatchRazor) + { + g_descCaptureBatchRazor = + reasampler::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); + } + // Register the "capture selected track (realtime)" action (command_id -> gaccel -> // hookcommand). Realtime sibling of the offline CAPTURE_TRACK scope: records the // selected track's own output in realtime into a hidden temp track, moves it into diff --git a/tests/test_batch_capture.cpp b/tests/test_batch_capture.cpp new file mode 100644 index 0000000..3cb8e93 --- /dev/null +++ b/tests/test_batch_capture.cpp @@ -0,0 +1,162 @@ +// Standalone tests for reasampler::batch_capture — no REAPER, no test framework. +// Same fast loop as the sibling pure tests (render_settings et al.): assert the batch +// planning (selected-set -> ordered capture units) and result aggregation directly. +// +// Covers (M11 brief §test cases): +// * selected-set -> capture-request-list planning: items; razor areas; empty set; +// multiple razor areas across tracks (order preserved, ordinals contiguous); +// invalid (empty/inverted) ranges dropped. +// * batch result aggregation: all-success; partial-failure ORDERING (failed +// ordinals reported in unit order); all-failed; single-unit noun singular. + +#include "../src/batch_capture.h" + +#include +#include +#include + +using namespace reasampler; + +static int g_fail = 0; +#define CHECK(cond) do { if(!(cond)) { \ + std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) + +// --- planCaptureUnits: items ------------------------------------------------- + +// Three selected items -> three units, order preserved, ordinals 1,2,3, bounds exact. +static void testPlanItemsPreservesOrderAndBounds() { + std::vector in = {{0.0, 1.5}, {2.0, 2.25}, {10.0, 12.0}}; + auto units = planCaptureUnits(in); + CHECK(units.size() == 3); + CHECK(units[0].ordinal == 1 && units[0].startSeconds == 0.0 && units[0].endSeconds == 1.5); + CHECK(units[1].ordinal == 2 && units[1].startSeconds == 2.0 && units[1].endSeconds == 2.25); + CHECK(units[2].ordinal == 3 && units[2].startSeconds == 10.0 && units[2].endSeconds == 12.0); +} + +// --- planCaptureUnits: empty set --------------------------------------------- + +// No selected item / no razor area -> empty plan (shell reports nothing-to-batch). +static void testPlanEmptySet() { + CHECK(planCaptureUnits({}).empty()); +} + +// --- planCaptureUnits: invalid ranges dropped, ordinals stay contiguous ------ + +// An empty (end==start) and an inverted (end in = { + {0.0, 1.0}, // kept -> #1 + {5.0, 5.0}, // empty -> dropped + {3.0, 2.0}, // inverted -> dropped + {8.0, 9.0}, // kept -> #2 + }; + auto units = planCaptureUnits(in); + CHECK(units.size() == 2); + CHECK(units[0].ordinal == 1 && units[0].startSeconds == 0.0 && units[0].endSeconds == 1.0); + CHECK(units[1].ordinal == 2 && units[1].startSeconds == 8.0 && units[1].endSeconds == 9.0); +} + +// --- planCaptureUnits: multiple razor areas across tracks -------------------- + +// The shell concatenates every track's parsed razor areas in track order; planning +// preserves that concatenation order and assigns contiguous ordinals. Two areas on +// track A followed by one on track B -> ordinals 1,2,3 in that exact order. +static void testPlanRazorAreasAcrossTracksOrdered() { + std::vector in = { + {0.0, 0.5}, // track A, area 1 + {1.0, 1.5}, // track A, area 2 + {4.0, 6.0}, // track B, area 1 + }; + auto units = planCaptureUnits(in); + CHECK(units.size() == 3); + CHECK(units[0].startSeconds == 0.0 && units[0].endSeconds == 0.5); + CHECK(units[1].startSeconds == 1.0 && units[1].endSeconds == 1.5); + CHECK(units[2].startSeconds == 4.0 && units[2].endSeconds == 6.0); + CHECK(units[0].ordinal == 1 && units[1].ordinal == 2 && units[2].ordinal == 3); +} + +// --- BatchOutcome: all-success ----------------------------------------------- + +static void testOutcomeAllSuccess() { + BatchOutcome o; + o.record(1, true); + o.record(2, true); + o.record(3, true); + CHECK(o.total() == 3); + CHECK(o.succeeded() == 3); + CHECK(o.failed() == 0); + CHECK(o.failures().empty()); + CHECK(o.summaryLine("item") == "ReaSampler batch capture: 3 items captured."); +} + +// --- BatchOutcome: partial-failure ordering ---------------------------------- + +// Failures are reported in UNIT ORDER (#2, #4), not record-count order or sorted — +// the summary must let the user retry exactly the right units. This assertion fails +// if the aggregation reordered, deduped, or dropped a failure. +static void testOutcomePartialFailureOrdering() { + BatchOutcome o; + o.record(1, true); + o.record(2, false, "render produced no output file"); + o.record(3, true); + o.record(4, false, "capture range is empty"); + o.record(5, true); + CHECK(o.total() == 5); + CHECK(o.succeeded() == 3); + CHECK(o.failed() == 2); + + auto fails = o.failures(); + CHECK(fails.size() == 2); + CHECK(fails[0].ordinal == 2 && !fails[0].ok); + CHECK(fails[0].detail == "render produced no output file"); + CHECK(fails[1].ordinal == 4); + + CHECK(o.summaryLine("item") == + "ReaSampler batch capture: 3 of 5 items captured (2 failed: #2, #4)."); +} + +// --- BatchOutcome: all-failed ------------------------------------------------ + +static void testOutcomeAllFailed() { + BatchOutcome o; + o.record(1, false, "x"); + o.record(2, false, "y"); + CHECK(o.succeeded() == 0); + CHECK(o.failed() == 2); + CHECK(o.summaryLine("razor area") == + "ReaSampler batch capture: 0 of 2 razor areas captured (2 failed: #1, #2)."); +} + +// --- BatchOutcome: single unit uses singular noun ---------------------------- + +static void testOutcomeSingularNoun() { + BatchOutcome o; + o.record(1, true); + CHECK(o.summaryLine("item") == "ReaSampler batch capture: 1 item captured."); +} + +// --- BatchOutcome: empty (no units planned) ---------------------------------- + +static void testOutcomeEmpty() { + BatchOutcome o; + CHECK(o.total() == 0); + CHECK(o.summaryLine("item") == "ReaSampler batch capture: nothing to capture."); +} + +int main() { + testPlanItemsPreservesOrderAndBounds(); + testPlanEmptySet(); + testPlanDropsInvalidAndRenumbers(); + testPlanRazorAreasAcrossTracksOrdered(); + + testOutcomeAllSuccess(); + testOutcomePartialFailureOrdering(); + testOutcomeAllFailed(); + testOutcomeSingularNoun(); + testOutcomeEmpty(); + + if (g_fail == 0) std::printf("All tests passed.\n"); + return g_fail ? 1 : 0; +}