capture: render a ranged item capture time-bounded — the selected-items source can't narrow a window, only a full-extent one uses it
sourceModeForScope now takes the item extent vs. the requested window. Full-extent item captures and the batch keep the old path unchanged.
This commit is contained in:
@@ -12,6 +12,7 @@ add_library(reaper_reasampler MODULE
|
||||
${REASAMPLER_SRC_DIR}/shell/capture/capture_batch.cpp
|
||||
${REASAMPLER_SRC_DIR}/shell/capture/bake_land.cpp
|
||||
${REASAMPLER_SRC_DIR}/shell/capture/scope_resolve.cpp
|
||||
${REASAMPLER_SRC_DIR}/shell/capture/render_selection.cpp
|
||||
${REASAMPLER_SRC_DIR}/shell/capture/realtime_lifecycle.cpp
|
||||
${REASAMPLER_SRC_DIR}/shell/capture/capture_realtime_shell.cpp
|
||||
${REASAMPLER_SRC_DIR}/shell/capture/capture_realtime_finalize.cpp
|
||||
@@ -45,7 +46,7 @@ add_library(reaper_reasampler MODULE
|
||||
${REASAMPLER_SRC_DIR}/shell/actions/instrument_drop_win.cpp
|
||||
${REASAMPLER_SRC_DIR}/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 view_tree guid_diff lane_keys insert_plan render_settings batch_capture tail_control capture_realtime bank_book wav_codec origin_ledger tracking_authority 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 bake_wire resample_name)
|
||||
target_link_libraries(reaper_reasampler PRIVATE json wire file_bytes bank_model capture_paths peaks bank_grid mode_switch tab_strip view_mode_model view_tree guid_diff lane_keys insert_plan render_settings render_window batch_capture tail_control capture_realtime bank_book wav_codec origin_ledger tracking_authority 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 bake_wire resample_name)
|
||||
# NOT linked here, deliberately: sampler_core / pitch_shift / the filter. The instrument
|
||||
# renders its own bake in its own process, which is what keeps the extension's link graph
|
||||
# free of the voice engine — a link edge to it here means the design drifted.
|
||||
|
||||
@@ -51,6 +51,7 @@ Detail specific to these pure modules:
|
||||
- `capture_paths` — the REAPER-free path arithmetic behind offline capture: bank-subfolder + unique-filename derivation (`deriveBankPaths`, forward-slash form, no filesystem touch), the absolute-render-dir vs. project-relative-index-path split (`BankPaths`), the persist-side inverse (`resolveBankFile`, `projectDirOfRpp`), the Save-As bank-relocation plan (`deriveRelocationPlan`), and the GUID-primary project-identity classifier (`classifyProjectTransition` → `NoOp`/`Load`/`SaveAsRelocate`) the persist-poll timer drives.
|
||||
- `insert_plan` — the REAPER-free logic behind the `insert` shell (M6): computes the `InsertMedia` `mode` bitmask from an `InsertOptions` struct (placement target, tempo-conform ratio, preserve-pitch flag), guaranteeing the &4 stretch-to-time-selection bit is never set and that no tempo bits are set when `conform == None`.
|
||||
- `render_settings` — the REAPER-free logic behind the capture action family: `SourceMode` → `RENDER_SETTINGS` bit mapping, `P_RAZOREDITS` string parsing + range-union bounds, razor-else-time range inference, the FX-scope bypass plan (`fxBypassPlanFor`), the tail-mode → `RENDER_TAILFLAG`/`RENDER_NORMALIZE`/`RENDER_TRIMEND` mapping (`tailRenderSettingsFor`) and its realtime-window analog (`realtimeRecordWindowEnd`), and the capture-action taxonomy table (`captureActionTable`) `main.cpp` iterates to register the CAPTURE_ITEM/CAPTURE_TRACK family.
|
||||
- `render_window` — the REAPER-free frame arithmetic behind exact capture bounds: `frameCountFor` (the frame count a project-time window occupies at the project rate — the number a capture's file must match) and `itemExtentPrintsWindow`, the predicate `render_settings::sourceModeForScope` consults to decide whether REAPER's selected-items render source can express a requested window at all.
|
||||
- `tail_control` — the REAPER-free logic behind the docked `bank_panel`'s tail-mode toggle: the cycle order (None → Auto → Manual → None), the Manual-length clamp/scroll-wheel fine-adjust (`clampManualMs`/`adjustManualMs`, 250 ms/notch, 2000 ms default), the toggle's label text (e.g. "Tail: Manual 2.0s"), and the `TailSetting` JSON round-trip persist stores per-project.
|
||||
|
||||
## Gotchas
|
||||
@@ -60,6 +61,14 @@ Detail specific to these pure modules:
|
||||
(`reaper_plugin_functions.h` lines ~3041/~3047/~3051/~3062) — re-verify
|
||||
against the header before changing any bit value, per the root `CLAUDE.md`
|
||||
API-verification rule.
|
||||
- **The selected-items render source (`&32`) cannot narrow a window** — REAPER
|
||||
derives that render's bounds from the selected items' own extents, so
|
||||
`RENDER_BOUNDSFLAG=0` + `RENDER_STARTPOS`/`RENDER_ENDPOS` do not constrain it.
|
||||
This is an inference from the observed defect (a time selection inside a long
|
||||
item captured the whole item), NOT a header-confirmed fact. It is why
|
||||
`sourceModeForScope` routes item scope to `&32` only when the item extent
|
||||
already IS the requested window — do not re-point item scope unconditionally at
|
||||
`&32`, and do not widen the `&32` branch to windows it cannot express.
|
||||
- `kRenderPreFaderStems` (&8192) is deliberately **not** used — REAPER offline
|
||||
render has no true pre-FX "dry" bit; FX scoping is done entirely by the
|
||||
FX-bypass-around-render mechanism, never by a render bit.
|
||||
|
||||
@@ -7,6 +7,9 @@ reasampler_test(insert_plan LINK insert_plan)
|
||||
reasampler_pure_library(render_settings SOURCES render_settings.cpp LINK PUBLIC bank_model)
|
||||
reasampler_test(render_settings LINK render_settings)
|
||||
|
||||
reasampler_pure_library(render_window SOURCES render_window.cpp)
|
||||
reasampler_test(render_window LINK render_window)
|
||||
|
||||
reasampler_pure_library(batch_capture SOURCES batch_capture.cpp)
|
||||
reasampler_test(batch_capture LINK batch_capture)
|
||||
|
||||
|
||||
@@ -100,12 +100,16 @@ RenderSettingsChoice renderSettingsFor(SourceMode mode, double /*wetDry*/) {
|
||||
return c;
|
||||
}
|
||||
|
||||
SourceMode sourceModeForScope(CaptureScope scope) {
|
||||
SourceMode sourceModeForScope(CaptureScope scope, bool itemExtentIsWindow) {
|
||||
switch (scope) {
|
||||
case CaptureScope::Item: return SourceMode::SelectedItems;
|
||||
case CaptureScope::Item:
|
||||
return itemExtentIsWindow ? SourceMode::SelectedItems
|
||||
: SourceMode::SelectedTracks;
|
||||
case CaptureScope::Track: return SourceMode::SelectedTracks;
|
||||
}
|
||||
return SourceMode::SelectedItems; // unreachable for a valid enum; fail closed
|
||||
// Unreachable for a valid enum; fail closed to the time-bounded render, which
|
||||
// honors the requested bounds whatever the selection is.
|
||||
return SourceMode::SelectedTracks;
|
||||
}
|
||||
|
||||
RangeSource inferRangeSource(bool hasRazorArea) {
|
||||
|
||||
@@ -105,9 +105,18 @@ enum class CaptureScope {
|
||||
Track,
|
||||
};
|
||||
|
||||
// The render source mode each scope drives. Item captures selected items, Track
|
||||
// captures selected tracks (via master).
|
||||
SourceMode sourceModeForScope(CaptureScope scope);
|
||||
// The render source mode each scope drives. Track scope always captures its
|
||||
// selected tracks (via master), time-bounded by RENDER_STARTPOS/ENDPOS.
|
||||
//
|
||||
// Item scope captures the selected items ONLY when `itemExtentIsWindow` — i.e.
|
||||
// when those items' own extent already prints the requested window (see
|
||||
// render_window::itemExtentPrintsWindow). REAPER's selected-items render source
|
||||
// derives the render's bounds from the item extents, so a window strictly inside
|
||||
// (or wider than) a selected item cannot be expressed through it; that case
|
||||
// renders time-bounded through the items' own tracks. The FX scope is unaffected
|
||||
// either way — fxBypassPlanFor is keyed on CaptureScope, not on the source mode,
|
||||
// so an item capture still hears take/item FX only.
|
||||
SourceMode sourceModeForScope(CaptureScope scope, bool itemExtentIsWindow);
|
||||
|
||||
// --- Range inference: razor-else-time (orthogonal to scope) -------------------
|
||||
//
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
// render_window.cpp — see the header.
|
||||
|
||||
#include "core/capture/render_window.h"
|
||||
|
||||
#include <cmath>
|
||||
|
||||
namespace reasampler::capture {
|
||||
|
||||
namespace {
|
||||
|
||||
// Round-to-nearest, so a position that sits mid-frame maps to the frame a render
|
||||
// of it prints rather than to the frame below it.
|
||||
long long frameIndexAt(double seconds, int sampleRate) {
|
||||
return std::llround(seconds * static_cast<double>(sampleRate));
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
long long frameCountFor(double startSeconds, double endSeconds, int sampleRate) {
|
||||
if (sampleRate <= 0) return 0;
|
||||
if (!(endSeconds > startSeconds)) return 0;
|
||||
const long long frames =
|
||||
frameIndexAt(endSeconds, sampleRate) - frameIndexAt(startSeconds, sampleRate);
|
||||
return frames > 0 ? frames : 0;
|
||||
}
|
||||
|
||||
bool itemExtentPrintsWindow(double reqStart, double reqEnd,
|
||||
double itemStart, double itemEnd,
|
||||
int sampleRate) {
|
||||
if (sampleRate <= 0)
|
||||
return reqStart == itemStart && reqEnd == itemEnd;
|
||||
return frameIndexAt(reqStart, sampleRate) == frameIndexAt(itemStart, sampleRate)
|
||||
&& frameIndexAt(reqEnd, sampleRate) == frameIndexAt(itemEnd, sampleRate);
|
||||
}
|
||||
|
||||
} // namespace reasampler::capture
|
||||
@@ -0,0 +1,27 @@
|
||||
#pragma once
|
||||
// render_window — pure frame arithmetic for a capture's requested window: the
|
||||
// frame count a project-time range occupies, and whether a render whose bounds
|
||||
// come from the selected items' own extent already prints that window.
|
||||
// NO REAPER types; unit-tested by tests/test_render_window.cpp.
|
||||
|
||||
namespace reasampler::capture {
|
||||
|
||||
// Frames the [startSeconds, endSeconds) window occupies at `sampleRate`. Both
|
||||
// edges are resolved to the NEAREST frame boundary and subtracted, so the answer
|
||||
// is a difference of frame indices rather than a rounded duration — two windows
|
||||
// of equal length at different offsets can legitimately differ by one frame.
|
||||
// Returns 0 for a non-positive rate or an empty/inverted window.
|
||||
long long frameCountFor(double startSeconds, double endSeconds, int sampleRate);
|
||||
|
||||
// True when a render bounded by the selected items' own extent
|
||||
// [itemStart, itemEnd) already prints exactly the requested
|
||||
// [reqStart, reqEnd) window — the one case where REAPER's selected-items render
|
||||
// source needs no correction. Compared at frame resolution, because a sub-frame
|
||||
// difference prints the same frames. An unknown rate (<= 0) falls back to exact
|
||||
// equality, which can only send a window to the time-bounded render, never widen
|
||||
// one.
|
||||
bool itemExtentPrintsWindow(double reqStart, double reqEnd,
|
||||
double itemStart, double itemEnd,
|
||||
int sampleRate);
|
||||
|
||||
} // namespace reasampler::capture
|
||||
@@ -20,6 +20,11 @@ relative-paths-only) and the load-bearing capture/placement separation — see r
|
||||
`CLAUDE.md` §Precision invariants and §The load-bearing principle. Shell-specific
|
||||
detail not covered there:
|
||||
|
||||
- **The item scope's render source is window-dependent.** `ResolveScopeSource` is
|
||||
where that is decided — it measures the selected items' extent against the
|
||||
resolved range (`core/capture/render_window`) and hands the answer to
|
||||
`sourceModeForScope` on `ResolvedSource`. Why, in
|
||||
`src/core/capture/CLAUDE.md`.
|
||||
- **FX-bypass guard ordering.** `scope_resolve` reads the M10 provenance-assembly
|
||||
inputs (track/item selection, FX-chain identity) BEFORE the FX-bypass guard
|
||||
neutralizes the in-scope chain — provenance must see the chain as it really is,
|
||||
@@ -36,6 +41,7 @@ detail not covered there:
|
||||
|
||||
- `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`.
|
||||
- `scope_resolve` (`shell/capture`) — scope/source resolution shared by every capture entry point (Q-W3 hoist out of `main.cpp`): razor-else-time range inference, selected-track/selected-item-owning-track collection with canonical GUIDs, and the M10 provenance-assembly inputs (read BEFORE the FX-bypass guard neutralizes the in-scope chain).
|
||||
- `render_selection` (`shell/capture`) — the transient track selection a selected-tracks render (`&128`) requires, as a stack RAII guard: REAPER prints whatever tracks are selected, so `renderOffline` makes the request's own tracks BE the selection for the render's duration and restores the user's set on every exit path. Engaged only for that source mode; a ranged item capture and re-capture-from-source both name tracks the user has not selected.
|
||||
- `capture_orchestrator` (`shell/capture`) — single-capture orchestration + the realtime/insert action bodies (Q-W3 hoist, T4-02): `renderOffline` (one offline render under the scope's FX-bypass guard), `captureAndIndexOne` (render + provenance stamp + bank add + tracking-ledger record, unpersisted), `RunCapture`/`RunCaptureItemAssign`, `RunCaptureRealtimeTrack`/`RunCancelRealtime` (the realtime action bodies — the in-flight state lives in `realtime_lifecycle`), and `RunInsertSelected` (the ONE deliberate exception to capture-never-places).
|
||||
- `bake_land` (`shell/capture`) — the EXTENSION's half of the resample chain: scans every open project tab for pending `rsbake_*` requests, lands the ones belonging to the project this session has loaded, and refuses the rest with `WrongProject` — one undo point for the batch, each answered over its own key inside the invoking instance's synchronous action call. It RENDERS NOTHING — the instrument already did, through its own engine in its own process, which is what makes the baked audio the sound the user approved and what keeps the voice engine out of the extension's link graph. Replace-vs-add comes from `tracking::resampleLanding`; a replace keeps the entry's id and slot and never deletes the superseded file. Hash-dedup applies on the add path only, before the disk write, matching `updateSampleInPlace`'s "an in-place refresh is not an insert". A refused index withdraws the bytes this call had just written — the self-cleanup carve-out from prune's deletion authority, stated in `prune_fs.cpp`'s header.
|
||||
- `capture_batch` (`shell/capture`) — the batch-capture family + re-capture-from-source (Q-W3 hoist, T4-02): `RunBatchCaptureItems` (one sample per selected item), `RunBatchCaptureRazor` (one sample per razor area), `RunRecaptureFromSource` (regenerate a provenanced sample from its recorded source's current state, bank-only). Every unit routes through `capture_orchestrator` so every precision invariant holds; persist is batched to one ext-state write per action.
|
||||
|
||||
@@ -214,6 +214,11 @@ void RunBatchCaptureItems(ReaSamplerSession& session)
|
||||
src.startSeconds = unit.startSeconds;
|
||||
src.endSeconds = unit.endSeconds;
|
||||
src.sourceTracks.push_back(u.track);
|
||||
// The unit's range IS this item's own extent (read above from
|
||||
// D_POSITION/D_LENGTH) and it is the only selected item, so the
|
||||
// selected-items render source prints exactly it — batch keeps the
|
||||
// one-sample-per-item-at-item-extent semantics, unchanged.
|
||||
src.itemExtentIsWindow = true;
|
||||
if (std::string g = guidString(u.track); !g.empty())
|
||||
src.trackGuids.push_back(std::move(g));
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
#include "shell/persist/session.h" // ReaSamplerSession
|
||||
#include "shell/capture/insert.h" // runInsert / InsertRequest
|
||||
#include "shell/capture/realtime_lifecycle.h" // the in-flight realtime state
|
||||
#include "shell/capture/render_selection.h" // RenderTrackSelection
|
||||
|
||||
#include "reaper_plugin.h" // UNDO_STATE_MISCCFG
|
||||
|
||||
@@ -181,6 +182,14 @@ CaptureResult renderOffline(CaptureScope scope,
|
||||
const CaptureRequest& req)
|
||||
{
|
||||
ReaProject* proj = EnumProjects(-1, nullptr, 0);
|
||||
// A selected-tracks render prints the DAW's track selection, so the request's
|
||||
// own tracks must BE that selection for the render — the plain track scope
|
||||
// already resolved them from the live selection (identity), but a ranged item
|
||||
// capture and a re-capture-from-source both name tracks the user has not
|
||||
// selected. Both guards outlive the render call and restore on every path.
|
||||
std::optional<RenderTrackSelection> selection;
|
||||
if (req.sourceMode == SourceMode::SelectedTracks)
|
||||
selection.emplace(sourceTracks);
|
||||
FxBypassGuard fxGuard(scope, sourceTracks, proj);
|
||||
OfflineRenderBackend backend;
|
||||
return backend.capture(req);
|
||||
@@ -217,7 +226,7 @@ CaptureResult captureAndIndexOne(ReaSamplerSession& session,
|
||||
const TailSetting tail = bankPanelTailSetting();
|
||||
|
||||
CaptureRequest req;
|
||||
req.sourceMode = sourceModeForScope(scope);
|
||||
req.sourceMode = sourceModeForScope(scope, src.itemExtentIsWindow);
|
||||
req.startSeconds = startSeconds; // exact bounds — no rounding
|
||||
req.endSeconds = endSeconds;
|
||||
req.wetDry = 1.0; // wet post the FX left enabled by the scope
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
// render_selection.cpp — 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.
|
||||
|
||||
#include "shell/capture/render_selection.h"
|
||||
|
||||
#define REAPERAPI_MINIMAL
|
||||
#define REAPERAPI_WANT_CountTracks
|
||||
#define REAPERAPI_WANT_GetTrack
|
||||
#define REAPERAPI_WANT_CountSelectedTracks
|
||||
#define REAPERAPI_WANT_GetSelectedTrack
|
||||
#define REAPERAPI_WANT_SetTrackSelected
|
||||
#include "reaper_plugin_functions.h"
|
||||
|
||||
namespace reasampler::capture {
|
||||
|
||||
namespace {
|
||||
|
||||
// Deselect every track in GetTrack's index space (which excludes the master, SDK
|
||||
// ~2634), then select exactly `tracks` — so the resulting selection is the set,
|
||||
// not the set unioned with whatever was already selected.
|
||||
void selectOnly(const std::vector<MediaTrack*>& tracks)
|
||||
{
|
||||
const int total = CountTracks(nullptr);
|
||||
for (int i = 0; i < total; ++i)
|
||||
if (MediaTrack* tr = GetTrack(nullptr, i))
|
||||
SetTrackSelected(tr, false);
|
||||
for (MediaTrack* tr : tracks)
|
||||
if (tr) SetTrackSelected(tr, true);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
RenderTrackSelection::RenderTrackSelection(const std::vector<MediaTrack*>& tracks)
|
||||
{
|
||||
if (tracks.empty()) return;
|
||||
|
||||
const int n = CountSelectedTracks(nullptr); // nullptr = active project
|
||||
for (int i = 0; i < n; ++i)
|
||||
if (MediaTrack* tr = GetSelectedTrack(nullptr, i))
|
||||
original_.push_back(tr);
|
||||
|
||||
selectOnly(tracks);
|
||||
applied_ = true;
|
||||
}
|
||||
|
||||
RenderTrackSelection::~RenderTrackSelection()
|
||||
{
|
||||
if (applied_) selectOnly(original_);
|
||||
}
|
||||
|
||||
} // namespace reasampler::capture
|
||||
@@ -0,0 +1,34 @@
|
||||
#pragma once
|
||||
// The transient DAW track selection a selected-tracks render requires. REAPER's
|
||||
// &128 source prints whatever tracks are selected, not the tracks the request
|
||||
// names, so the render owns the selection for its duration and hands it back.
|
||||
// The .cpp includes reaper_plugin_functions.h WITHOUT REAPERAPI_IMPLEMENT.
|
||||
|
||||
#include <vector>
|
||||
|
||||
#include "shell/capture/capture.h" // MediaTrack fwd
|
||||
|
||||
namespace reasampler::capture {
|
||||
|
||||
// RAII: selects exactly `tracks`, restores the project's original track selection
|
||||
// on every exit path (non-destructive — selection flags only, no restructuring).
|
||||
// Two callers need this and neither has the right selection standing: a ranged
|
||||
// item capture renders through the items' tracks while the user's track selection
|
||||
// is unrelated, and re-capture-from-source resolves its tracks by GUID and selects
|
||||
// nothing at all. An empty `tracks` is a deliberate no-op — forcing an empty
|
||||
// selection would render silence.
|
||||
class RenderTrackSelection
|
||||
{
|
||||
public:
|
||||
explicit RenderTrackSelection(const std::vector<MediaTrack*>& tracks);
|
||||
~RenderTrackSelection();
|
||||
|
||||
RenderTrackSelection(const RenderTrackSelection&) = delete;
|
||||
RenderTrackSelection& operator=(const RenderTrackSelection&) = delete;
|
||||
|
||||
private:
|
||||
std::vector<MediaTrack*> original_;
|
||||
bool applied_ = false;
|
||||
};
|
||||
|
||||
} // namespace reasampler::capture
|
||||
@@ -10,6 +10,7 @@
|
||||
#include <filesystem> // project-dir derivation for provenance parent resolution
|
||||
#include <utility>
|
||||
|
||||
#include "core/capture/render_window.h" // itemExtentPrintsWindow
|
||||
#include "shell/capture/provenance_shell.h" // fxChainIdentity* / *SourceFiles / bankFileRefs
|
||||
#include "shell/capture/track_guid.h" // guidString
|
||||
|
||||
@@ -19,9 +20,11 @@
|
||||
#define REAPERAPI_WANT_GetTrack
|
||||
#define REAPERAPI_WANT_GetSetMediaTrackInfo_String
|
||||
#define REAPERAPI_WANT_EnumProjects
|
||||
#define REAPERAPI_WANT_GetSetProjectInfo
|
||||
#define REAPERAPI_WANT_CountSelectedMediaItems
|
||||
#define REAPERAPI_WANT_GetSelectedMediaItem
|
||||
#define REAPERAPI_WANT_GetMediaItem_Track
|
||||
#define REAPERAPI_WANT_GetMediaItemInfo_Value
|
||||
#define REAPERAPI_WANT_CountSelectedTracks
|
||||
#define REAPERAPI_WANT_GetSelectedTrack
|
||||
#include "reaper_plugin_functions.h"
|
||||
@@ -50,14 +53,26 @@ model::ProvenanceScope provenanceScopeFor(CaptureScope scope)
|
||||
// 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. GUIDs recorded for provenance.
|
||||
bool collectSelectedItemTracks(ResolvedSource& out)
|
||||
// extentStart/extentEnd come back as the union of the selected items' own extents:
|
||||
// the window REAPER's selected-items render source would print (SDK ~1990:
|
||||
// D_POSITION/D_LENGTH in seconds).
|
||||
bool collectSelectedItemTracks(ResolvedSource& out,
|
||||
double& extentStart, double& extentEnd)
|
||||
{
|
||||
const int n = CountSelectedMediaItems(nullptr);
|
||||
if (n <= 0) return false;
|
||||
bool anyExtent = false;
|
||||
for (int i = 0; i < n; ++i)
|
||||
{
|
||||
MediaItem* it = GetSelectedMediaItem(nullptr, i);
|
||||
if (!it) continue;
|
||||
const double pos = GetMediaItemInfo_Value(it, "D_POSITION");
|
||||
const double len = GetMediaItemInfo_Value(it, "D_LENGTH");
|
||||
if (!anyExtent) { extentStart = pos; extentEnd = pos + len; anyExtent = true; }
|
||||
else {
|
||||
if (pos < extentStart) extentStart = pos;
|
||||
if (pos + len > extentEnd) extentEnd = pos + len;
|
||||
}
|
||||
MediaTrack* tr = GetMediaItem_Track(it);
|
||||
if (!tr) continue;
|
||||
// Dedup: several selected items can share a track.
|
||||
@@ -71,6 +86,16 @@ bool collectSelectedItemTracks(ResolvedSource& out)
|
||||
return !out.sourceTracks.empty();
|
||||
}
|
||||
|
||||
// 0 when the project never pinned a rate (and nominal unless PROJECT_SRATE_USE is
|
||||
// set, SDK ~3064) — passed through as "unknown", which the pure window comparison
|
||||
// handles by falling back to exact equality.
|
||||
int projectSampleRate()
|
||||
{
|
||||
ReaProject* proj = EnumProjects(-1, nullptr, 0);
|
||||
if (!proj) return 0;
|
||||
return static_cast<int>(GetSetProjectInfo(proj, "PROJECT_SRATE", 0.0, false));
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
// Reads every track's P_RAZOREDITS (SDK header ~2899: space-separated triples of
|
||||
@@ -128,10 +153,11 @@ bool collectSelectedTracks(ResolvedSource& out)
|
||||
|
||||
bool ResolveScopeSource(CaptureScope scope, ResolvedSource& out, std::string& why)
|
||||
{
|
||||
double itemExtentStart = 0.0, itemExtentEnd = 0.0;
|
||||
switch (scope)
|
||||
{
|
||||
case CaptureScope::Item:
|
||||
if (!collectSelectedItemTracks(out)) {
|
||||
if (!collectSelectedItemTracks(out, itemExtentStart, itemExtentEnd)) {
|
||||
why = "select at least one media item"; return false;
|
||||
}
|
||||
break;
|
||||
@@ -141,7 +167,13 @@ bool ResolveScopeSource(CaptureScope scope, ResolvedSource& out, std::string& wh
|
||||
}
|
||||
break;
|
||||
}
|
||||
return resolveRange(out.startSeconds, out.endSeconds, why);
|
||||
if (!resolveRange(out.startSeconds, out.endSeconds, why)) return false;
|
||||
|
||||
if (scope == CaptureScope::Item)
|
||||
out.itemExtentIsWindow = itemExtentPrintsWindow(
|
||||
out.startSeconds, out.endSeconds, itemExtentStart, itemExtentEnd,
|
||||
projectSampleRate());
|
||||
return true;
|
||||
}
|
||||
|
||||
// Empty for an unsaved project (EnumProjects writes an empty .rpp path), which
|
||||
|
||||
@@ -34,6 +34,12 @@ struct ResolvedSource
|
||||
double endSeconds = 0.0;
|
||||
std::vector<MediaTrack*> sourceTracks;
|
||||
std::vector<std::string> trackGuids;
|
||||
|
||||
// Item scope only: does the selected items' own extent already print
|
||||
// [startSeconds, endSeconds)? Feeds sourceModeForScope. Defaults false so a
|
||||
// hand-built source fails closed to the time-bounded render — a caller whose
|
||||
// range IS the item extent (batch item capture) says so explicitly.
|
||||
bool itemExtentIsWindow = false;
|
||||
};
|
||||
|
||||
// Reads every track's P_RAZOREDITS and returns the union of parsed track-audio
|
||||
|
||||
@@ -203,14 +203,43 @@ static void testRazorUnionBounds() {
|
||||
// --- sourceModeForScope: scope -> render source mode -------------------------
|
||||
|
||||
static void testScopeSourceModes() {
|
||||
// Each scope drives a distinct render source. Item -> items, Track -> tracks.
|
||||
// (There is no master scope — to capture the master you render a track.) These
|
||||
// feed renderSettingsFor and must be supported.
|
||||
CHECK(sourceModeForScope(CaptureScope::Item) == SourceMode::SelectedItems);
|
||||
CHECK(sourceModeForScope(CaptureScope::Track) == SourceMode::SelectedTracks);
|
||||
// Track scope always renders its selected tracks (there is no master scope — to
|
||||
// capture the master you render a track), whatever the window.
|
||||
CHECK(sourceModeForScope(CaptureScope::Track, true) == SourceMode::SelectedTracks);
|
||||
CHECK(sourceModeForScope(CaptureScope::Track, false) == SourceMode::SelectedTracks);
|
||||
// Item scope renders the selected items only when their extent already prints
|
||||
// the requested window.
|
||||
CHECK(sourceModeForScope(CaptureScope::Item, true) == SourceMode::SelectedItems);
|
||||
// Every scope's source mode is an offline-supported render source.
|
||||
CHECK(renderSettingsFor(sourceModeForScope(CaptureScope::Item), 1.0).supported);
|
||||
CHECK(renderSettingsFor(sourceModeForScope(CaptureScope::Track), 1.0).supported);
|
||||
CHECK(renderSettingsFor(sourceModeForScope(CaptureScope::Item, true), 1.0).supported);
|
||||
CHECK(renderSettingsFor(sourceModeForScope(CaptureScope::Track, true), 1.0).supported);
|
||||
}
|
||||
|
||||
static void testRangedItemScopeRendersTimeBounded() {
|
||||
// The Ψ.7 defect pinned at the bit level. A window the item extent does NOT
|
||||
// print must never reach the &32 selected-items source: that source takes its
|
||||
// bounds from the item extents, so RENDER_STARTPOS/ENDPOS cannot narrow it and
|
||||
// the capture widens to the whole item. The ranged item capture renders through
|
||||
// the selected-tracks source instead, which IS time-bounded.
|
||||
const SourceMode ranged = sourceModeForScope(CaptureScope::Item, false);
|
||||
CHECK(ranged == SourceMode::SelectedTracks);
|
||||
|
||||
const RenderSettingsChoice c = renderSettingsFor(ranged, 1.0);
|
||||
CHECK(c.supported);
|
||||
CHECK((c.settings & kRenderSelItems) == 0); // the widening bit is absent
|
||||
CHECK((c.settings & kRenderSingleFile) == 0); // and its single-file companion
|
||||
CHECK(c.settings == kRenderSelTracksViaMaster);
|
||||
|
||||
// The regression floor, at the same resolution: an item capture whose window IS
|
||||
// the item extent still renders through &32 | single-file, unchanged.
|
||||
const RenderSettingsChoice floorCase =
|
||||
renderSettingsFor(sourceModeForScope(CaptureScope::Item, true), 1.0);
|
||||
CHECK((floorCase.settings & kRenderSelItems) != 0);
|
||||
CHECK((floorCase.settings & kRenderSingleFile) != 0);
|
||||
|
||||
// FX scope is orthogonal to the re-source: a ranged item capture still hears
|
||||
// take/item FX only (this is what makes the swap safe).
|
||||
CHECK(fxBypassPlanFor(CaptureScope::Item).bypassSelfFx);
|
||||
}
|
||||
|
||||
// --- inferRangeSource: razor-else-time (orthogonal to scope) -----------------
|
||||
@@ -262,8 +291,10 @@ static void testTableHasBothScopes() {
|
||||
// double-prefix bug, so assert its ABSENCE.
|
||||
CHECK(suffix.rfind("CEREBELLUM_REASAMPLER_", 0) != 0);
|
||||
CHECK(ids.insert(suffix).second); // false if duplicate
|
||||
// Every scope resolves to a supported offline source.
|
||||
CHECK(renderSettingsFor(sourceModeForScope(def.scope), 1.0).supported);
|
||||
// Every scope resolves to a supported offline source, on BOTH the
|
||||
// extent-prints-the-window path and the time-bounded one.
|
||||
CHECK(renderSettingsFor(sourceModeForScope(def.scope, true), 1.0).supported);
|
||||
CHECK(renderSettingsFor(sourceModeForScope(def.scope, false), 1.0).supported);
|
||||
|
||||
if (def.scope == CaptureScope::Item) ++item;
|
||||
if (def.scope == CaptureScope::Track) ++track;
|
||||
@@ -307,6 +338,7 @@ int main() {
|
||||
testParseEmptyAndMalformed();
|
||||
testRazorUnionBounds();
|
||||
testScopeSourceModes();
|
||||
testRangedItemScopeRendersTimeBounded();
|
||||
testRangeInference();
|
||||
testItemScopeBypassesEverythingButTake();
|
||||
testTrackScopeKeepsSelfBypassesAncestorsAndMaster();
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
// Standalone tests for reasampler::render_window — no REAPER, no framework.
|
||||
// Covers the bounds-equality number (a window's exact frame count at the project
|
||||
// rate) and the predicate that decides whether REAPER's selected-items render
|
||||
// source can express a requested window at all.
|
||||
|
||||
#include "../src/core/capture/render_window.h"
|
||||
|
||||
#include <cstdio>
|
||||
|
||||
using namespace reasampler::capture;
|
||||
|
||||
static int g_fail = 0;
|
||||
#define CHECK(cond) do { if(!(cond)) { \
|
||||
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
|
||||
|
||||
// --- frameCountFor: the bounds equality, stated as a number ------------------
|
||||
|
||||
static void testFrameCountIsExactNotRounded() {
|
||||
// A 1.5 s window at 48 kHz is exactly 72000 frames — the number a capture of
|
||||
// that range must produce. No rounding slack in either direction.
|
||||
CHECK(frameCountFor(2.0, 3.5, 48000) == 72000);
|
||||
// The same duration at a different offset still counts the same frames when
|
||||
// both edges are frame-aligned.
|
||||
CHECK(frameCountFor(10.0, 11.5, 48000) == 72000);
|
||||
// 44.1 kHz: 0.5 s = 22050 frames.
|
||||
CHECK(frameCountFor(1.0, 1.5, 44100) == 22050);
|
||||
}
|
||||
|
||||
static void testFrameCountIsADifferenceOfIndicesNotADuration() {
|
||||
// Both edges land mid-frame at 100 Hz (0.005 s = half a frame). Rounding the
|
||||
// DURATION would give 1 frame; rounding each EDGE gives 0.005 -> frame 1 and
|
||||
// 0.015 -> frame 2, i.e. 1 frame. Shift the window so the edges round apart
|
||||
// and the count changes — the property that makes this a window, not a length.
|
||||
CHECK(frameCountFor(0.005, 0.015, 100) == 1);
|
||||
CHECK(frameCountFor(0.004, 0.016, 100) == 2);
|
||||
}
|
||||
|
||||
static void testFrameCountRefusesEmptyInvertedAndUnknownRate() {
|
||||
CHECK(frameCountFor(3.0, 3.0, 48000) == 0); // empty
|
||||
CHECK(frameCountFor(3.0, 1.0, 48000) == 0); // inverted
|
||||
CHECK(frameCountFor(1.0, 2.0, 0) == 0); // rate unknown
|
||||
CHECK(frameCountFor(1.0, 2.0, -1) == 0); // rate nonsensical
|
||||
}
|
||||
|
||||
// --- itemExtentPrintsWindow: can the selected-items source express this? -----
|
||||
|
||||
static void testRangeInsideItemCannotBeExpressed() {
|
||||
// The defect this whole module exists for: a 1 s selection inside a 30 s item.
|
||||
// The selected-items source would print the item's 30 s, not the 1 s asked for,
|
||||
// so the capture must NOT take that path.
|
||||
CHECK(!itemExtentPrintsWindow(5.0, 6.0, /*item*/ 0.0, 30.0, 48000));
|
||||
}
|
||||
|
||||
static void testRangeWiderThanItemCannotBeExpressedEither() {
|
||||
// The same violation in the other direction: a 10 s selection over a 6 s item
|
||||
// would print 6 s. Under-printing is a bounds violation exactly as much as
|
||||
// over-printing is.
|
||||
CHECK(!itemExtentPrintsWindow(0.0, 10.0, /*item*/ 2.0, 8.0, 48000));
|
||||
}
|
||||
|
||||
static void testEachEdgeAloneDisqualifies() {
|
||||
// Matching start, drifting end.
|
||||
CHECK(!itemExtentPrintsWindow(2.0, 8.0, 2.0, 9.0, 48000));
|
||||
// Matching end, drifting start.
|
||||
CHECK(!itemExtentPrintsWindow(2.0, 8.0, 1.0, 8.0, 48000));
|
||||
}
|
||||
|
||||
static void testExtentEqualToWindowIsExpressible() {
|
||||
// The regression floor: a capture whose range IS the item's extent keeps the
|
||||
// selected-items render, byte-identical to what it produces today.
|
||||
CHECK(itemExtentPrintsWindow(2.0, 8.0, 2.0, 8.0, 48000));
|
||||
}
|
||||
|
||||
static void testSubFrameDriftStillPrintsTheSameFrames() {
|
||||
// A time selection snapped a fraction of a sample off the item edge prints the
|
||||
// identical frames, so it must NOT be pushed onto the time-bounded path — that
|
||||
// would swap the render mechanism under a capture that was already exact.
|
||||
const double eighthOfAFrameAt48k = 1.0 / (48000.0 * 8.0);
|
||||
CHECK(itemExtentPrintsWindow(2.0 + eighthOfAFrameAt48k, 8.0 - eighthOfAFrameAt48k,
|
||||
2.0, 8.0, 48000));
|
||||
// A full frame of drift is a real difference and must disqualify.
|
||||
const double oneFrameAt48k = 1.0 / 48000.0;
|
||||
CHECK(!itemExtentPrintsWindow(2.0 + oneFrameAt48k, 8.0, 2.0, 8.0, 48000));
|
||||
}
|
||||
|
||||
static void testUnknownRateFallsBackToExactEquality() {
|
||||
// With no project rate there is no frame grid to compare on. Exact equality
|
||||
// still recognizes the regression floor...
|
||||
CHECK(itemExtentPrintsWindow(2.0, 8.0, 2.0, 8.0, 0));
|
||||
// ...and anything else takes the time-bounded render, which honors the request
|
||||
// whatever the rate turns out to be.
|
||||
const double eighthOfAFrameAt48k = 1.0 / (48000.0 * 8.0);
|
||||
CHECK(!itemExtentPrintsWindow(2.0 + eighthOfAFrameAt48k, 8.0, 2.0, 8.0, 0));
|
||||
CHECK(!itemExtentPrintsWindow(5.0, 6.0, 0.0, 30.0, 0));
|
||||
}
|
||||
|
||||
static void testMultiItemUnionExtent() {
|
||||
// Two items spanning 1..4 and 6..9 present a 1..9 union extent to the render.
|
||||
// A selection over the whole union is expressible; one over only the first
|
||||
// item's half is not.
|
||||
CHECK(itemExtentPrintsWindow(1.0, 9.0, 1.0, 9.0, 48000));
|
||||
CHECK(!itemExtentPrintsWindow(1.0, 4.0, 1.0, 9.0, 48000));
|
||||
}
|
||||
|
||||
int main() {
|
||||
testFrameCountIsExactNotRounded();
|
||||
testFrameCountIsADifferenceOfIndicesNotADuration();
|
||||
testFrameCountRefusesEmptyInvertedAndUnknownRate();
|
||||
testRangeInsideItemCannotBeExpressed();
|
||||
testRangeWiderThanItemCannotBeExpressedEither();
|
||||
testEachEdgeAloneDisqualifies();
|
||||
testExtentEqualToWindowIsExpressible();
|
||||
testSubFrameDriftStillPrintsTheSameFrames();
|
||||
testUnknownRateFallsBackToExactEquality();
|
||||
testMultiItemUnionExtent();
|
||||
|
||||
if (g_fail) { std::printf("%d check(s) FAILED\n", g_fail); return 1; }
|
||||
std::printf("render_window: all checks passed\n");
|
||||
return 0;
|
||||
}
|
||||
Reference in New Issue
Block a user