Merge Ψ-W1-T1: a ranged item capture renders the window, with children and receives silenced for it
# Conflicts: # docs/TODO.md # src/app/CMakeLists.txt
This commit is contained in:
+1
-1
@@ -2127,7 +2127,7 @@ internal-drag path").
|
|||||||
|
|
||||||
| Track | Owns |
|
| Track | Owns |
|
||||||
|---|---|
|
|---|---|
|
||||||
| **T1** `capture-range-exactness` | `core/capture/render_settings`, `shell/capture/capture.cpp`'s render-configuration block, `shell/capture/scope_resolve` (source-mode selection only), `tests/test_render_settings.cpp` |
|
| **T1** `capture-range-exactness` | `core/capture/render_settings`, `shell/capture/capture.cpp`'s render-configuration block, `shell/capture/scope_resolve` (source-mode selection only), `tests/test_render_settings.cpp` — **plus, as landed:** the new pure `core/capture/render_window` + `core/capture/track_topology`, the new shells `shell/capture/render_selection` + `shell/capture/render_isolation`, and `renderOffline`'s guard block in `shell/capture/capture_orchestrator.cpp` (the one seam a fresh capture and a recipe replay both cross, so the refusal and the transient guards had to live there rather than in `scope_resolve`) |
|
||||||
| **T2** `mode-switch-discipline` | `core/view/view_mode_model` (solo cache), `shell/view/view.cpp` (`applyMode` seams + WANT block), `shell/actions/design_view_actions.cpp` (refusal feedback), `shell/panel/panel_input.cpp` **footer mode-segment block only** (`:300-309`), the mode segment's disabled state in `core/ui/footer_bar` + `shell/panel/panel_render.cpp` |
|
| **T2** `mode-switch-discipline` | `core/view/view_mode_model` (solo cache), `shell/view/view.cpp` (`applyMode` seams + WANT block), `shell/actions/design_view_actions.cpp` (refusal feedback), `shell/panel/panel_input.cpp` **footer mode-segment block only** (`:300-309`), the mode segment's disabled state in `core/ui/footer_bar` + `shell/panel/panel_render.cpp` |
|
||||||
| **T3** `media-explorer-section` | `shell/actions/ingest.cpp` (register/dispatch/unregister), **the registration block in `src/app/main.cpp`** (the `hookcommand2` hook + unload mirror), the root-`CLAUDE.md` contract amendment |
|
| **T3** `media-explorer-section` | `shell/actions/ingest.cpp` (register/dispatch/unregister), **the registration block in `src/app/main.cpp`** (the `hookcommand2` hook + unload mirror), the root-`CLAUDE.md` contract amendment |
|
||||||
| **T4** `drop-target-resolution` | `core/ui/drag_out`, `core/wire/instrument_drop`, `shell/panel/panel_drag.cpp`, `shell/actions/instrument_drop_win.cpp`, `shell/actions/drag_out_win.cpp` (hand-off timing), `tests/test_drag_out.cpp` |
|
| **T4** `drop-target-resolution` | `core/ui/drag_out`, `core/wire/instrument_drop`, `shell/panel/panel_drag.cpp`, `shell/actions/instrument_drop_win.cpp`, `shell/actions/drag_out_win.cpp` (hand-off timing), `tests/test_drag_out.cpp` |
|
||||||
|
|||||||
@@ -482,3 +482,98 @@ CTest target driven by a fake `reaper_plugin_info_t`; the Media-Explorer section
|
|||||||
registration and `ingestHandleSectionCommand` move into it and gain unit coverage; and
|
registration and `ingestHandleSectionCommand` move into it and gain unit coverage; and
|
||||||
`ingest.cpp`'s own `command_id`+`gaccel` registration collapses onto
|
`ingest.cpp`'s own `command_id`+`gaccel` registration collapses onto
|
||||||
`action_registry::registerAction` where the shapes match.
|
`action_registry::registerAction` where the shapes match.
|
||||||
|
|
||||||
|
## A multi-track TRACK capture renders one file per track and lands one of them
|
||||||
|
|
||||||
|
**Context (surfaced by Ψ-W1-T1, capture-range-exactness).** Track scope has always
|
||||||
|
rendered through `RENDER_SETTINGS &128` ("selected tracks via master"), and the
|
||||||
|
ranged item capture now joins it there. The SDK header (~3041) documents the
|
||||||
|
single-file bit `&(4<<16)` for "rendering selected items or razor edits" only, so it
|
||||||
|
does not apply to `&128`: the reading is that N selected tracks produce N files.
|
||||||
|
`capture.cpp` sets `RENDER_PATTERN` to one literal stem and detects success by
|
||||||
|
`std::filesystem::exists`, so N stems collapse onto one name and whichever file
|
||||||
|
survives lands as a successful capture carrying one track's audio.
|
||||||
|
|
||||||
|
**The wart.** `renderOffline` refuses this shape for the ranged ITEM capture
|
||||||
|
(`render_settings::isMultiTrackRangedItemRender`) because that path was newly routed
|
||||||
|
into it. Track scope with two or more tracks selected has the same exposure and is
|
||||||
|
deliberately untouched — changing a shipped action from "produces a file" to
|
||||||
|
"refuses" is a behavioral-contract change, and it was out of that track's surface.
|
||||||
|
|
||||||
|
**Intended fix.** Not proposed. Three shapes exist and the choice is a product call,
|
||||||
|
not a mechanical one: refuse (matching the item path), render each track and land N
|
||||||
|
bank entries (that is batch capture's meaning, not this action's), or sum the
|
||||||
|
selected tracks into one file (needs a summing render source `&128` does not offer).
|
||||||
|
|
||||||
|
**The constraint the fix MUST handle.** The per-track-output reading of `&128` is
|
||||||
|
INFERRED from the header's single-file wording, never observed in a DAW. Verify it
|
||||||
|
first — including what REAPER actually writes when N stems share one literal
|
||||||
|
`RENDER_PATTERN` — because the answer decides whether this is a defect at all.
|
||||||
|
|
||||||
|
**Priority / risk.** Unknown until the DAW check above runs. If the reading is right,
|
||||||
|
a silently-wrong capture on an ordinary two-track selection; if wrong, nothing.
|
||||||
|
|
||||||
|
**Done looks like.** The `&128` multi-track output shape is DAW-confirmed, and track
|
||||||
|
scope either produces defined correct output for a multi-track selection or refuses
|
||||||
|
it with a message naming the reason.
|
||||||
|
|
||||||
|
## A `SelectedItems` recipe replays against whatever items are selected then
|
||||||
|
|
||||||
|
**Context (surfaced by Ψ-W1-T1, capture-range-exactness).** `RunRecaptureFromSource`
|
||||||
|
rebuilds a `CaptureRequest` from the recorded `CaptureRecipe` and resolves its source
|
||||||
|
tracks by GUID. `renderOffline` engages `RenderTrackSelection` only when the recipe's
|
||||||
|
source mode is `SelectedTracks`, which is what makes a ranged item capture and a
|
||||||
|
track capture replay against their recorded tracks rather than the live selection.
|
||||||
|
|
||||||
|
**The wart.** A recipe whose source mode is `SelectedItems` — every pre-fix item-scope
|
||||||
|
capture, and every post-fix full-extent one — renders `&32`, which prints whatever
|
||||||
|
items happen to be selected when the replay fires. The recorded recipe therefore does
|
||||||
|
not fully determine the audio it reproduces, which is what "recapture from source"
|
||||||
|
promises.
|
||||||
|
|
||||||
|
**Intended fix.** Not proposed. The recipe stores tracks and a range; it carries no
|
||||||
|
item GUIDs, so no guard on the shell side can reconstruct the item selection from
|
||||||
|
what is recorded. Closing it means widening `CaptureRecipe` (a wire-format change with
|
||||||
|
a version rung) or re-sourcing full-extent item captures through the tracks render too,
|
||||||
|
which would drag them onto the isolation path for no gain.
|
||||||
|
|
||||||
|
**The constraint the fix MUST handle.** Widening the recipe must keep every already-
|
||||||
|
persisted recipe readable, and must not make a replay depend on items that no longer
|
||||||
|
exist — a deleted source item has to degrade to a stated refusal, not a silent
|
||||||
|
substitution.
|
||||||
|
|
||||||
|
**Priority / risk.** Pre-existing; not introduced or worsened by the range-exactness
|
||||||
|
work. Harmless when the user re-runs a recapture with the same items still selected,
|
||||||
|
wrong when they do not.
|
||||||
|
|
||||||
|
**Done looks like.** A `SelectedItems` recapture either reproduces its recorded audio
|
||||||
|
from the recipe alone, or refuses with a message naming what the recipe cannot pin
|
||||||
|
down.
|
||||||
|
|
||||||
|
## An overlapping item on the source track itself is not isolated from a ranged item capture — DECIDED, not deferred
|
||||||
|
|
||||||
|
**Context (surfaced by Ψ-W1-T1, capture-range-exactness).** The re-source to the
|
||||||
|
selected-tracks render (`&128`) needed transient upstream silencing so an item capture
|
||||||
|
did not also print folder children and receives; `render_isolation` (`UpstreamIsolation`)
|
||||||
|
covers both. A third widening exists in the same shape: a non-selected item on the
|
||||||
|
SAME track that overlaps the requested range is now audible in the render, where the
|
||||||
|
pre-fix `&32` selected-items source excluded it by construction (that source only ever
|
||||||
|
prints the selected items).
|
||||||
|
|
||||||
|
**This is a decision, not a gap.** `src/shell/capture/CLAUDE.md` states the reasoning in
|
||||||
|
full and it is not repeated here: `UpstreamIsolation`/`render_selection` silence and
|
||||||
|
select TRACKS because the recipe that replays a capture stores tracks and a range, never
|
||||||
|
item GUIDs — a mute plan keyed to today's overlapping item could not be recomputed at
|
||||||
|
replay time, so muting items would make the capture stop reproducing itself. The named
|
||||||
|
candidate (a) in `docs/PLAN.md` §Ψ-W1-T1 carried exactly this semantic edge; it was
|
||||||
|
weighed against candidate (b) (an item-bounds render with a derived start time) and (a)
|
||||||
|
shipped with the edge accepted rather than closed.
|
||||||
|
|
||||||
|
**Priority / risk.** Low in the common case (one item per track over the captured range is
|
||||||
|
the normal shape); a project with deliberately overlapping items on one track is the one
|
||||||
|
that surfaces it, and the practical mitigation is unchanged from before this track:
|
||||||
|
select/move the neighbour, or capture at track scope instead.
|
||||||
|
|
||||||
|
**Done looks like.** Nothing to do — recorded so a future reviewer does not read the
|
||||||
|
non-isolation as an oversight and re-propose closing it against the recipe's stated
|
||||||
|
tracks-and-range-only shape.
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ add_library(reaper_reasampler MODULE
|
|||||||
${REASAMPLER_SRC_DIR}/shell/capture/capture_batch.cpp
|
${REASAMPLER_SRC_DIR}/shell/capture/capture_batch.cpp
|
||||||
${REASAMPLER_SRC_DIR}/shell/capture/bake_land.cpp
|
${REASAMPLER_SRC_DIR}/shell/capture/bake_land.cpp
|
||||||
${REASAMPLER_SRC_DIR}/shell/capture/scope_resolve.cpp
|
${REASAMPLER_SRC_DIR}/shell/capture/scope_resolve.cpp
|
||||||
|
${REASAMPLER_SRC_DIR}/shell/capture/render_selection.cpp
|
||||||
|
${REASAMPLER_SRC_DIR}/shell/capture/render_isolation.cpp
|
||||||
${REASAMPLER_SRC_DIR}/shell/capture/realtime_lifecycle.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_shell.cpp
|
||||||
${REASAMPLER_SRC_DIR}/shell/capture/capture_realtime_finalize.cpp
|
${REASAMPLER_SRC_DIR}/shell/capture/capture_realtime_finalize.cpp
|
||||||
@@ -47,7 +49,7 @@ add_library(reaper_reasampler MODULE
|
|||||||
${REASAMPLER_SRC_DIR}/shell/actions/instrument_drop_win.cpp
|
${REASAMPLER_SRC_DIR}/shell/actions/instrument_drop_win.cpp
|
||||||
${REASAMPLER_SRC_DIR}/shell/persist/usage_scan.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 solo_cache 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 solo_cache insert_plan render_settings render_window track_topology 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
|
# 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
|
# 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.
|
# free of the voice engine — a link edge to it here means the design drifted.
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ 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.
|
- `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`.
|
- `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_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 the offline backend checks the rendered file against before landing it, so a widened render is refused rather than banked) and `itemExtentPrintsWindow`, the predicate `render_settings::sourceModeForScope` consults to decide whether REAPER's selected-items render source can express a requested window at all.
|
||||||
|
- `track_topology` — the REAPER-free folder arithmetic over a project's flat `I_FOLDERDEPTH` delta list: `directChildIndices` names a folder parent's DIRECT children, the set `shell/capture/render_isolation` silences so a ranged item capture does not print its track's children. Grandchildren are excluded by construction — they reach the parent only through the child that owns them.
|
||||||
- `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.
|
- `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
|
## Gotchas
|
||||||
@@ -60,6 +62,22 @@ Detail specific to these pure modules:
|
|||||||
(`reaper_plugin_functions.h` lines ~3041/~3047/~3051/~3062) — re-verify
|
(`reaper_plugin_functions.h` lines ~3041/~3047/~3051/~3062) — re-verify
|
||||||
against the header before changing any bit value, per the root `CLAUDE.md`
|
against the header before changing any bit value, per the root `CLAUDE.md`
|
||||||
API-verification rule.
|
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. This is the
|
||||||
|
one home for that inference; the sites that act on it point here rather than
|
||||||
|
restating it.
|
||||||
|
- **The re-source changes the CONTENT, not the FX scope.** `fxBypassPlanFor` is keyed
|
||||||
|
on `CaptureScope`, so a ranged item capture still hears take/item FX only — but the
|
||||||
|
selected-tracks source prints everything upstream of the track. The shell answers
|
||||||
|
that with a transient silencing (`shell/capture/render_isolation`) whose child-set
|
||||||
|
walk lives here in `track_topology`; the item-vs-track asymmetry behind it is in
|
||||||
|
`src/shell/capture/CLAUDE.md`.
|
||||||
- `kRenderPreFaderStems` (&8192) is deliberately **not** used — REAPER offline
|
- `kRenderPreFaderStems` (&8192) is deliberately **not** used — REAPER offline
|
||||||
render has no true pre-FX "dry" bit; FX scoping is done entirely by the
|
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.
|
FX-bypass-around-render mechanism, never by a render bit.
|
||||||
|
|||||||
@@ -7,6 +7,12 @@ reasampler_test(insert_plan LINK insert_plan)
|
|||||||
reasampler_pure_library(render_settings SOURCES render_settings.cpp LINK PUBLIC bank_model)
|
reasampler_pure_library(render_settings SOURCES render_settings.cpp LINK PUBLIC bank_model)
|
||||||
reasampler_test(render_settings LINK render_settings)
|
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(track_topology SOURCES track_topology.cpp)
|
||||||
|
reasampler_test(track_topology LINK track_topology)
|
||||||
|
|
||||||
reasampler_pure_library(batch_capture SOURCES batch_capture.cpp)
|
reasampler_pure_library(batch_capture SOURCES batch_capture.cpp)
|
||||||
reasampler_test(batch_capture LINK batch_capture)
|
reasampler_test(batch_capture LINK batch_capture)
|
||||||
|
|
||||||
|
|||||||
@@ -100,12 +100,23 @@ RenderSettingsChoice renderSettingsFor(SourceMode mode, double /*wetDry*/) {
|
|||||||
return c;
|
return c;
|
||||||
}
|
}
|
||||||
|
|
||||||
SourceMode sourceModeForScope(CaptureScope scope) {
|
SourceMode sourceModeForScope(CaptureScope scope, bool itemExtentIsWindow) {
|
||||||
switch (scope) {
|
switch (scope) {
|
||||||
case CaptureScope::Item: return SourceMode::SelectedItems;
|
case CaptureScope::Item:
|
||||||
|
return itemExtentIsWindow ? SourceMode::SelectedItems
|
||||||
|
: SourceMode::SelectedTracks;
|
||||||
case CaptureScope::Track: return 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool isMultiTrackRangedItemRender(CaptureScope scope, SourceMode mode,
|
||||||
|
int sourceTrackCount) {
|
||||||
|
return scope == CaptureScope::Item
|
||||||
|
&& mode == SourceMode::SelectedTracks
|
||||||
|
&& sourceTrackCount > 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
RangeSource inferRangeSource(bool hasRazorArea) {
|
RangeSource inferRangeSource(bool hasRazorArea) {
|
||||||
|
|||||||
@@ -105,9 +105,38 @@ enum class CaptureScope {
|
|||||||
Track,
|
Track,
|
||||||
};
|
};
|
||||||
|
|
||||||
// The render source mode each scope drives. Item captures selected items, Track
|
// The render source mode each scope drives. Track scope always captures its
|
||||||
// captures selected tracks (via master).
|
// selected tracks (via master), time-bounded by RENDER_STARTPOS/ENDPOS.
|
||||||
SourceMode sourceModeForScope(CaptureScope scope);
|
//
|
||||||
|
// 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 is
|
||||||
|
// INFERRED to derive its 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 inference is unverified — see
|
||||||
|
// src/core/capture/CLAUDE.md §Gotchas for what it rests on.
|
||||||
|
//
|
||||||
|
// The FX SCOPE is unaffected by the swap (fxBypassPlanFor is keyed on CaptureScope,
|
||||||
|
// not on the source mode, so an item capture still hears take/item FX only), but the
|
||||||
|
// CONTENT reaching the render is not: the selected-tracks source prints everything
|
||||||
|
// upstream of the track — its folder children and its receives — which the shell
|
||||||
|
// transiently silences (shell/capture/render_isolation). An overlapping item on the
|
||||||
|
// track ITSELF is deliberately not isolated; see src/shell/capture/CLAUDE.md.
|
||||||
|
SourceMode sourceModeForScope(CaptureScope scope, bool itemExtentIsWindow);
|
||||||
|
|
||||||
|
// True for the one render shape that cannot land as a single capture: an item-scope
|
||||||
|
// capture re-sourced to the selected-tracks render (its window is not the item
|
||||||
|
// extent) whose selected items span more than one track. That source is read as
|
||||||
|
// rendering one file per selected track — the single-file bit is documented for
|
||||||
|
// item/razor sources only (SDK header ~3041), which is the whole basis for the
|
||||||
|
// reading and is DAW-unverified. If it holds, N tracks collapse N stems onto one
|
||||||
|
// literal render pattern and whichever file survived would land as a successful
|
||||||
|
// capture carrying one track's audio. The caller refuses instead.
|
||||||
|
//
|
||||||
|
// Track scope is deliberately NOT covered here even though it renders through the
|
||||||
|
// same source — see src/shell/capture/CLAUDE.md §Gotchas.
|
||||||
|
bool isMultiTrackRangedItemRender(CaptureScope scope, SourceMode mode,
|
||||||
|
int sourceTrackCount);
|
||||||
|
|
||||||
// --- Range inference: razor-else-time (orthogonal to scope) -------------------
|
// --- 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,33 @@
|
|||||||
|
#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.
|
||||||
|
//
|
||||||
|
// The offline backend compares this against the rendered file's own frame count, so
|
||||||
|
// exact-bounds failures surface as a refused capture rather than a wrong file. That
|
||||||
|
// REAPER resolves the two edges the same way is UNVERIFIED — a DAW pass decides
|
||||||
|
// whether the equality is exact or off by a frame.
|
||||||
|
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 is believed to need no correction (the bounds-override inference behind
|
||||||
|
// that is unverified; src/core/capture/CLAUDE.md §Gotchas states what it rests on).
|
||||||
|
// 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
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
// track_topology.cpp — see the header.
|
||||||
|
|
||||||
|
#include "core/capture/track_topology.h"
|
||||||
|
|
||||||
|
#include <cstddef>
|
||||||
|
|
||||||
|
namespace reasampler::capture {
|
||||||
|
|
||||||
|
std::vector<int> directChildIndices(const std::vector<int>& folderDepths,
|
||||||
|
int parentIndex) {
|
||||||
|
std::vector<int> children;
|
||||||
|
const int count = static_cast<int>(folderDepths.size());
|
||||||
|
if (parentIndex < 0 || parentIndex >= count) return children;
|
||||||
|
if (folderDepths[static_cast<std::size_t>(parentIndex)] != 1) return children;
|
||||||
|
|
||||||
|
// Depth relative to the parent: 1 immediately after it (inside its folder), and
|
||||||
|
// 0 once the folder closes. Only tracks sitting at relative depth 1 are direct
|
||||||
|
// children; a child that opens its own folder pushes the level to 2, which is
|
||||||
|
// what excludes its descendants.
|
||||||
|
int level = 1;
|
||||||
|
for (int i = parentIndex + 1; i < count && level > 0; ++i) {
|
||||||
|
if (level == 1) children.push_back(i);
|
||||||
|
level += folderDepths[static_cast<std::size_t>(i)];
|
||||||
|
}
|
||||||
|
return children;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace reasampler::capture
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
#pragma once
|
||||||
|
// track_topology — pure folder arithmetic over a project's track list: which tracks
|
||||||
|
// are the DIRECT children of a folder parent, derived from the I_FOLDERDEPTH deltas
|
||||||
|
// alone. NO REAPER types (the shell reads the deltas); unit-tested by
|
||||||
|
// tests/test_track_topology.cpp.
|
||||||
|
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace reasampler::capture {
|
||||||
|
|
||||||
|
// Indices of `parentIndex`'s DIRECT children, given every track's I_FOLDERDEPTH in
|
||||||
|
// track order. I_FOLDERDEPTH is a DELTA applied AFTER its own track (SDK header
|
||||||
|
// ~2215: 0 = normal, 1 = opens a folder, -n = closes n folders), so the depth walk
|
||||||
|
// below is the only way to recover the tree from the flat list.
|
||||||
|
//
|
||||||
|
// Empty when `parentIndex` is out of range or its track does not open a folder.
|
||||||
|
// Grandchildren are deliberately excluded: their audio reaches the parent only
|
||||||
|
// through the direct child that owns them, so a caller silencing each direct child's
|
||||||
|
// send-to-parent silences the whole subtree. An unterminated folder (no closing
|
||||||
|
// negative delta) treats every remaining track as inside it, matching REAPER.
|
||||||
|
std::vector<int> directChildIndices(const std::vector<int>& folderDepths,
|
||||||
|
int parentIndex);
|
||||||
|
|
||||||
|
} // namespace reasampler::capture
|
||||||
@@ -20,6 +20,23 @@ relative-paths-only) and the load-bearing capture/placement separation — see r
|
|||||||
`CLAUDE.md` §Precision invariants and §The load-bearing principle. Shell-specific
|
`CLAUDE.md` §Precision invariants and §The load-bearing principle. Shell-specific
|
||||||
detail not covered there:
|
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`.
|
||||||
|
- **A ranged item capture isolates TRACKS, not ITEMS.** Routing it through the
|
||||||
|
selected-tracks source widens what the render hears, and the two widenings are
|
||||||
|
answered differently. Folder children and receives are cut for the render's
|
||||||
|
duration (`render_isolation`) because they are tracks, and a recipe carrying
|
||||||
|
tracks can recompute that plan at replay time. An overlapping item on the source
|
||||||
|
track itself is NOT isolated: the recipe stores tracks and a range, never item
|
||||||
|
GUIDs, so a mute plan over items could not be replayed and the capture would stop
|
||||||
|
reproducing itself. Do not "fix" the second by muting items.
|
||||||
|
- **`renderOffline` is the one seam both a fresh capture and a recipe replay
|
||||||
|
cross**, which is why the refusal and both transient guards live there rather
|
||||||
|
than in the action bodies — anything placed in `ResolveScopeSource` alone would
|
||||||
|
miss `RunRecaptureFromSource` entirely.
|
||||||
- **FX-bypass guard ordering.** `scope_resolve` reads the M10 provenance-assembly
|
- **FX-bypass guard ordering.** `scope_resolve` reads the M10 provenance-assembly
|
||||||
inputs (track/item selection, FX-chain identity) BEFORE the FX-bypass guard
|
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,
|
neutralizes the in-scope chain — provenance must see the chain as it really is,
|
||||||
@@ -36,6 +53,8 @@ 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`.
|
- `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).
|
- `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, which leaves a stated residual: a `&32` selected-items render still prints whatever ITEMS the user has selected. Live captures are unaffected (that selection is the source), but a recipe replay of a `SelectedItems` capture renders against whatever happens to be selected then — the recipe stores tracks and a range, never item GUIDs, so this guard cannot close it. Filed in `docs/TODO.md`.
|
||||||
|
- `render_isolation` (`shell/capture`) — the transient upstream silencing a ranged ITEM render needs, as a stack RAII guard alongside the two above: the selected-tracks source prints everything flowing INTO the track, so each direct folder child's `B_MAINSEND` and each of the track's receives' `B_MUTE` are cut for the render and restored on every exit path. Direct children only — a grandchild reaches the track through the child that owns it. The child-set walk is pure (`core/capture/track_topology`).
|
||||||
- `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).
|
- `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.
|
- `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.
|
- `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.
|
||||||
@@ -54,3 +73,12 @@ detail not covered there:
|
|||||||
- `capture` and `capture_realtime_shell` deliberately share NO common interface with
|
- `capture` and `capture_realtime_shell` deliberately share NO common interface with
|
||||||
each other (the former `ICaptureBackend` was removed) — do not reintroduce one
|
each other (the former `ICaptureBackend` was removed) — do not reintroduce one
|
||||||
without a real second polymorphic call site.
|
without a real second polymorphic call site.
|
||||||
|
- **The selected-tracks render (`&128`) is read as emitting one file per selected
|
||||||
|
track** — the single-file bit is documented for item/razor sources only (SDK header
|
||||||
|
~3041), and that is the whole basis for the reading; it is DAW-unverified. If it
|
||||||
|
holds, then since `RENDER_PATTERN` is one literal stem and success is a file-exists
|
||||||
|
check, N tracks would land one track's audio as a successful capture.
|
||||||
|
`renderOffline` refuses that shape for the RANGED ITEM capture only
|
||||||
|
(`render_settings::isMultiTrackRangedItemRender`). Track scope renders through the
|
||||||
|
same source with the same exposure and is deliberately untouched here — filed in
|
||||||
|
`docs/TODO.md`.
|
||||||
|
|||||||
@@ -21,17 +21,20 @@
|
|||||||
#include "shell/capture/capture.h"
|
#include "shell/capture/capture.h"
|
||||||
|
|
||||||
#include <atomic>
|
#include <atomic>
|
||||||
|
#include <cmath>
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#include <ctime>
|
#include <ctime>
|
||||||
#include <filesystem>
|
#include <filesystem>
|
||||||
#include <fstream>
|
#include <fstream>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
#include <system_error>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
#include "core/capture/capture_paths.h"
|
#include "core/capture/capture_paths.h"
|
||||||
#include "core/capture/wav_codec.h" // hashWavContent — the one WAV/RIFF owner
|
#include "core/capture/wav_codec.h" // hashWavContent — the one WAV/RIFF owner
|
||||||
#include "core/util/file_bytes.h"
|
#include "core/util/file_bytes.h"
|
||||||
#include "core/capture/render_settings.h"
|
#include "core/capture/render_settings.h"
|
||||||
|
#include "core/capture/render_window.h" // frameCountFor — the exact-bounds number
|
||||||
|
|
||||||
#define REAPERAPI_MINIMAL
|
#define REAPERAPI_MINIMAL
|
||||||
#define REAPERAPI_WANT_EnumProjects
|
#define REAPERAPI_WANT_EnumProjects
|
||||||
@@ -394,6 +397,56 @@ CaptureResult OfflineRenderBackend::capture(const CaptureRequest& request) {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Exact bounds, made structural: with no tail requested the file must contain
|
||||||
|
// (within a tolerance, see below) the requested window's frames, so a source
|
||||||
|
// mode that silently widened the render fails loudly here instead of landing as
|
||||||
|
// a successful capture. Auto and Manual add frames by design and are skipped.
|
||||||
|
// (The file is read again by stampCaptureSample below; the duplicate read is a
|
||||||
|
// once-per-capture cost on an already-warm file.) A bounded/header-only read is
|
||||||
|
// not a clean substitute: parseWavLayout only marks the data chunk valid when
|
||||||
|
// the buffer holds the chunk's FULL declared body (bodyInBounds), so a truncated
|
||||||
|
// read would read as invalid here on every real capture, not just malformed ones.
|
||||||
|
if (request.tailMode == TailMode::None) {
|
||||||
|
const WavLayout layout =
|
||||||
|
parseWavLayout(util::readFileBytes(expectedPath));
|
||||||
|
const long long expectedFrames = layout.valid
|
||||||
|
? frameCountFor(request.startSeconds, request.endSeconds,
|
||||||
|
static_cast<int>(layout.sampleRate))
|
||||||
|
: 0;
|
||||||
|
const long long actualFrames = static_cast<long long>(layout.frameCount());
|
||||||
|
// frameCountFor is a difference of frame indices, not a rounded duration
|
||||||
|
// (see render_window.h) — REAPER's own edge-rounding can legitimately land
|
||||||
|
// one frame off that, so the gate tolerates +/-1 rather than exact equality.
|
||||||
|
// The defect this refuses is a whole-item widening (seconds of extra audio,
|
||||||
|
// thousands of frames), which a 1-frame tolerance still catches with
|
||||||
|
// certainty. Tightening to exact equality needs a DAW pass confirming REAPER
|
||||||
|
// resolves the window's two edges to frame indices the same way this does.
|
||||||
|
const long long frameDelta = actualFrames > expectedFrames
|
||||||
|
? actualFrames - expectedFrames
|
||||||
|
: expectedFrames - actualFrames;
|
||||||
|
if (expectedFrames > 0 && frameDelta > 1) {
|
||||||
|
result.status = CaptureStatus::BoundsMismatch;
|
||||||
|
result.message = "Render produced " + std::to_string(actualFrames) +
|
||||||
|
" frames but the requested range is " +
|
||||||
|
std::to_string(expectedFrames) + " at " +
|
||||||
|
std::to_string(layout.sampleRate) +
|
||||||
|
" Hz -- the render did not honor the requested bounds. "
|
||||||
|
"Requested [" + std::to_string(request.startSeconds) +
|
||||||
|
"s, " + std::to_string(request.endSeconds) +
|
||||||
|
"s) -> frame indices [" +
|
||||||
|
std::to_string(std::llround(request.startSeconds *
|
||||||
|
layout.sampleRate)) +
|
||||||
|
", " +
|
||||||
|
std::to_string(std::llround(request.endSeconds *
|
||||||
|
layout.sampleRate)) +
|
||||||
|
"). Nothing was added to the bank; the render at " +
|
||||||
|
expectedPath + " was never indexed and has been cleaned up.";
|
||||||
|
std::error_code ec;
|
||||||
|
std::filesystem::remove(expectedPath, ec);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Record the request's own bounds (exact) rather than re-measuring the file.
|
// Record the request's own bounds (exact) rather than re-measuring the file.
|
||||||
Sample s;
|
Sample s;
|
||||||
// Same uniqueTag that named the file — calling makeUniqueTag() again could
|
// Same uniqueTag that named the file — calling makeUniqueTag() again could
|
||||||
|
|||||||
@@ -71,6 +71,8 @@ enum class CaptureStatus {
|
|||||||
UnsupportedFormat, // requested bit depth has no known REAPER blob (Float32 only)
|
UnsupportedFormat, // requested bit depth has no known REAPER blob (Float32 only)
|
||||||
RenderFailed, // the render action ran but produced no output file
|
RenderFailed, // the render action ran but produced no output file
|
||||||
TransportBusy, // realtime backend: transport already playing/recording — refused
|
TransportBusy, // realtime backend: transport already playing/recording — refused
|
||||||
|
MultiTrackRange, // a ranged item capture whose items span >1 track — would render N files
|
||||||
|
BoundsMismatch, // the rendered file's frame count is not the requested window's
|
||||||
};
|
};
|
||||||
|
|
||||||
struct CaptureResult {
|
struct CaptureResult {
|
||||||
|
|||||||
@@ -214,6 +214,11 @@ void RunBatchCaptureItems(ReaSamplerSession& session)
|
|||||||
src.startSeconds = unit.startSeconds;
|
src.startSeconds = unit.startSeconds;
|
||||||
src.endSeconds = unit.endSeconds;
|
src.endSeconds = unit.endSeconds;
|
||||||
src.sourceTracks.push_back(u.track);
|
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())
|
if (std::string g = guidString(u.track); !g.empty())
|
||||||
src.trackGuids.push_back(std::move(g));
|
src.trackGuids.push_back(std::move(g));
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,8 @@
|
|||||||
#include "shell/persist/session.h" // ReaSamplerSession
|
#include "shell/persist/session.h" // ReaSamplerSession
|
||||||
#include "shell/capture/insert.h" // runInsert / InsertRequest
|
#include "shell/capture/insert.h" // runInsert / InsertRequest
|
||||||
#include "shell/capture/realtime_lifecycle.h" // the in-flight realtime state
|
#include "shell/capture/realtime_lifecycle.h" // the in-flight realtime state
|
||||||
|
#include "shell/capture/render_selection.h" // RenderTrackSelection
|
||||||
|
#include "shell/capture/render_isolation.h" // UpstreamIsolation
|
||||||
|
|
||||||
#include "reaper_plugin.h" // UNDO_STATE_MISCCFG
|
#include "reaper_plugin.h" // UNDO_STATE_MISCCFG
|
||||||
|
|
||||||
@@ -180,7 +182,41 @@ CaptureResult renderOffline(CaptureScope scope,
|
|||||||
const std::vector<MediaTrack*>& sourceTracks,
|
const std::vector<MediaTrack*>& sourceTracks,
|
||||||
const CaptureRequest& req)
|
const CaptureRequest& req)
|
||||||
{
|
{
|
||||||
|
// Refused BEFORE anything is touched, so the refusal path has nothing to
|
||||||
|
// restore. This is the seam BOTH a fresh capture and a recipe replay cross, so
|
||||||
|
// neither can land the multi-stem render the predicate names.
|
||||||
|
if (isMultiTrackRangedItemRender(scope, req.sourceMode,
|
||||||
|
static_cast<int>(sourceTracks.size())))
|
||||||
|
{
|
||||||
|
CaptureResult refused;
|
||||||
|
refused.status = CaptureStatus::MultiTrackRange;
|
||||||
|
refused.message =
|
||||||
|
"This range is narrower than the selected items, so it renders through "
|
||||||
|
"their tracks -- and those items span more than one track, which this "
|
||||||
|
"shape cannot land as a single file. Capture one track's items at a "
|
||||||
|
"time, or make the range match the items' extent.";
|
||||||
|
return refused;
|
||||||
|
}
|
||||||
|
|
||||||
ReaProject* proj = EnumProjects(-1, nullptr, 0);
|
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. Every guard below outlives the render call and restores on every path.
|
||||||
|
std::optional<RenderTrackSelection> selection;
|
||||||
|
std::optional<UpstreamIsolation> isolation;
|
||||||
|
if (req.sourceMode == SourceMode::SelectedTracks)
|
||||||
|
{
|
||||||
|
selection.emplace(sourceTracks);
|
||||||
|
// An ITEM capture routed through the tracks source would otherwise print
|
||||||
|
// everything upstream of the track — folder children, receives — which the
|
||||||
|
// selected-items source excluded. The refusal above is what makes the single
|
||||||
|
// source track here the whole set. Track scope is left alone: a folder
|
||||||
|
// parent's own output IS its children summed.
|
||||||
|
if (scope == CaptureScope::Item && !sourceTracks.empty())
|
||||||
|
isolation.emplace(sourceTracks.front());
|
||||||
|
}
|
||||||
FxBypassGuard fxGuard(scope, sourceTracks, proj);
|
FxBypassGuard fxGuard(scope, sourceTracks, proj);
|
||||||
OfflineRenderBackend backend;
|
OfflineRenderBackend backend;
|
||||||
return backend.capture(req);
|
return backend.capture(req);
|
||||||
@@ -217,7 +253,7 @@ CaptureResult captureAndIndexOne(ReaSamplerSession& session,
|
|||||||
const TailSetting tail = bankPanelTailSetting();
|
const TailSetting tail = bankPanelTailSetting();
|
||||||
|
|
||||||
CaptureRequest req;
|
CaptureRequest req;
|
||||||
req.sourceMode = sourceModeForScope(scope);
|
req.sourceMode = sourceModeForScope(scope, src.itemExtentIsWindow);
|
||||||
req.startSeconds = startSeconds; // exact bounds — no rounding
|
req.startSeconds = startSeconds; // exact bounds — no rounding
|
||||||
req.endSeconds = endSeconds;
|
req.endSeconds = endSeconds;
|
||||||
req.wetDry = 1.0; // wet post the FX left enabled by the scope
|
req.wetDry = 1.0; // wet post the FX left enabled by the scope
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
// render_isolation.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_isolation.h"
|
||||||
|
|
||||||
|
#include <cstddef>
|
||||||
|
|
||||||
|
#include "core/capture/track_topology.h" // directChildIndices
|
||||||
|
|
||||||
|
#define REAPERAPI_MINIMAL
|
||||||
|
#define REAPERAPI_WANT_CountTracks
|
||||||
|
#define REAPERAPI_WANT_GetTrack
|
||||||
|
#define REAPERAPI_WANT_GetMediaTrackInfo_Value
|
||||||
|
#define REAPERAPI_WANT_SetMediaTrackInfo_Value
|
||||||
|
#define REAPERAPI_WANT_GetTrackNumSends
|
||||||
|
#define REAPERAPI_WANT_GetTrackSendInfo_Value
|
||||||
|
#define REAPERAPI_WANT_SetTrackSendInfo_Value
|
||||||
|
#include "reaper_plugin_functions.h"
|
||||||
|
|
||||||
|
namespace reasampler::capture {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
// GetTrackNumSends / *TrackSendInfo_Value category: < 0 selects the RECEIVE list
|
||||||
|
// (SDK header ~3644, ~3676).
|
||||||
|
constexpr int kReceivesCategory = -1;
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
UpstreamIsolation::UpstreamIsolation(MediaTrack* track)
|
||||||
|
: track_(track)
|
||||||
|
{
|
||||||
|
if (!track_) return;
|
||||||
|
|
||||||
|
// One pass over the track list for both the depth deltas and this track's index;
|
||||||
|
// the pure walk turns the flat delta list into the direct-child set. The master
|
||||||
|
// is absent from GetTrack's index space (it is reached only via GetMasterTrack)
|
||||||
|
// and has no send-to-parent to cut, so nothing is missed by ignoring it.
|
||||||
|
const int total = CountTracks(nullptr);
|
||||||
|
std::vector<MediaTrack*> tracks;
|
||||||
|
std::vector<int> depths;
|
||||||
|
tracks.reserve(static_cast<std::size_t>(total < 0 ? 0 : total));
|
||||||
|
depths.reserve(static_cast<std::size_t>(total < 0 ? 0 : total));
|
||||||
|
int parentIndex = -1;
|
||||||
|
for (int i = 0; i < total; ++i)
|
||||||
|
{
|
||||||
|
MediaTrack* tr = GetTrack(nullptr, i);
|
||||||
|
tracks.push_back(tr);
|
||||||
|
depths.push_back(tr ? static_cast<int>(
|
||||||
|
GetMediaTrackInfo_Value(tr, "I_FOLDERDEPTH"))
|
||||||
|
: 0);
|
||||||
|
if (tr == track_) parentIndex = i;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int idx : directChildIndices(depths, parentIndex))
|
||||||
|
{
|
||||||
|
MediaTrack* child = tracks[static_cast<std::size_t>(idx)];
|
||||||
|
if (!child) continue;
|
||||||
|
// B_MAINSEND: "track sends audio to parent" (SDK header ~2238/~2957).
|
||||||
|
children_.push_back({child, GetMediaTrackInfo_Value(child, "B_MAINSEND")});
|
||||||
|
SetMediaTrackInfo_Value(child, "B_MAINSEND", 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const int receives = GetTrackNumSends(track_, kReceivesCategory);
|
||||||
|
receiveMutes_.reserve(static_cast<std::size_t>(receives < 0 ? 0 : receives));
|
||||||
|
for (int i = 0; i < receives; ++i)
|
||||||
|
{
|
||||||
|
receiveMutes_.push_back(
|
||||||
|
GetTrackSendInfo_Value(track_, kReceivesCategory, i, "B_MUTE"));
|
||||||
|
SetTrackSendInfo_Value(track_, kReceivesCategory, i, "B_MUTE", 1.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
UpstreamIsolation::~UpstreamIsolation()
|
||||||
|
{
|
||||||
|
for (std::size_t i = 0; i < receiveMutes_.size(); ++i)
|
||||||
|
SetTrackSendInfo_Value(track_, kReceivesCategory, static_cast<int>(i),
|
||||||
|
"B_MUTE", receiveMutes_[i]);
|
||||||
|
for (const ChildSend& c : children_)
|
||||||
|
SetMediaTrackInfo_Value(c.track, "B_MAINSEND", c.mainSend);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace reasampler::capture
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
#pragma once
|
||||||
|
// The transient silencing a ranged ITEM render needs: REAPER's selected-tracks
|
||||||
|
// source prints everything upstream of the track — its folder children and its
|
||||||
|
// receives — which an item capture must not hear. Stack RAII, restored on every
|
||||||
|
// path.
|
||||||
|
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "shell/capture/capture.h" // MediaTrack fwd
|
||||||
|
|
||||||
|
namespace reasampler::capture {
|
||||||
|
|
||||||
|
// RAII: for one render, cuts every route by which audio that is not `track`'s own
|
||||||
|
// items reaches `track` — each direct folder child's send-to-parent (B_MAINSEND) and
|
||||||
|
// each of `track`'s own receives (that receive's B_MUTE). Both are per-route levers,
|
||||||
|
// so a child or a sender keeps its FX, its fader, and its routing elsewhere intact.
|
||||||
|
// Every touched value is restored on every exit path; nothing is created or removed,
|
||||||
|
// so the project is unchanged afterwards (non-destructive invariant).
|
||||||
|
//
|
||||||
|
// An overlapping item on `track` ITSELF is deliberately NOT isolated — the reason is
|
||||||
|
// in src/shell/capture/CLAUDE.md.
|
||||||
|
class UpstreamIsolation
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
explicit UpstreamIsolation(MediaTrack* track);
|
||||||
|
~UpstreamIsolation();
|
||||||
|
|
||||||
|
UpstreamIsolation(const UpstreamIsolation&) = delete;
|
||||||
|
UpstreamIsolation& operator=(const UpstreamIsolation&) = delete;
|
||||||
|
|
||||||
|
private:
|
||||||
|
struct ChildSend {
|
||||||
|
MediaTrack* track = nullptr;
|
||||||
|
double mainSend = 0.0; // original B_MAINSEND
|
||||||
|
};
|
||||||
|
|
||||||
|
MediaTrack* track_ = nullptr;
|
||||||
|
std::vector<ChildSend> children_;
|
||||||
|
std::vector<double> receiveMutes_; // original B_MUTE, indexed by receive index
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace reasampler::capture
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
// 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, then select exactly `tracks` — so
|
||||||
|
// the resulting selection is the set, not the set unioned with whatever was already
|
||||||
|
// selected. That index space holds no master track (the master is reached only via
|
||||||
|
// GetMasterTrack); the header states this nowhere, so treat it as behavior rather
|
||||||
|
// than a documented guarantee.
|
||||||
|
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 <filesystem> // project-dir derivation for provenance parent resolution
|
||||||
#include <utility>
|
#include <utility>
|
||||||
|
|
||||||
|
#include "core/capture/render_window.h" // itemExtentPrintsWindow
|
||||||
#include "shell/capture/provenance_shell.h" // fxChainIdentity* / *SourceFiles / bankFileRefs
|
#include "shell/capture/provenance_shell.h" // fxChainIdentity* / *SourceFiles / bankFileRefs
|
||||||
#include "shell/capture/track_guid.h" // guidString
|
#include "shell/capture/track_guid.h" // guidString
|
||||||
|
|
||||||
@@ -19,9 +20,11 @@
|
|||||||
#define REAPERAPI_WANT_GetTrack
|
#define REAPERAPI_WANT_GetTrack
|
||||||
#define REAPERAPI_WANT_GetSetMediaTrackInfo_String
|
#define REAPERAPI_WANT_GetSetMediaTrackInfo_String
|
||||||
#define REAPERAPI_WANT_EnumProjects
|
#define REAPERAPI_WANT_EnumProjects
|
||||||
|
#define REAPERAPI_WANT_GetSetProjectInfo
|
||||||
#define REAPERAPI_WANT_CountSelectedMediaItems
|
#define REAPERAPI_WANT_CountSelectedMediaItems
|
||||||
#define REAPERAPI_WANT_GetSelectedMediaItem
|
#define REAPERAPI_WANT_GetSelectedMediaItem
|
||||||
#define REAPERAPI_WANT_GetMediaItem_Track
|
#define REAPERAPI_WANT_GetMediaItem_Track
|
||||||
|
#define REAPERAPI_WANT_GetMediaItemInfo_Value
|
||||||
#define REAPERAPI_WANT_CountSelectedTracks
|
#define REAPERAPI_WANT_CountSelectedTracks
|
||||||
#define REAPERAPI_WANT_GetSelectedTrack
|
#define REAPERAPI_WANT_GetSelectedTrack
|
||||||
#include "reaper_plugin_functions.h"
|
#include "reaper_plugin_functions.h"
|
||||||
@@ -50,14 +53,27 @@ model::ProvenanceScope provenanceScopeFor(CaptureScope scope)
|
|||||||
// Collects the tracks that own the selected items (Item scope) into
|
// 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
|
// 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.
|
// 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:
|
||||||
|
// INFERRED to be the window REAPER's selected-items render source would print
|
||||||
|
// (SDK ~1990: D_POSITION/D_LENGTH in seconds) — unverified; see
|
||||||
|
// src/core/capture/CLAUDE.md §Gotchas for what that inference rests on.
|
||||||
|
bool collectSelectedItemTracks(ResolvedSource& out,
|
||||||
|
double& extentStart, double& extentEnd)
|
||||||
{
|
{
|
||||||
const int n = CountSelectedMediaItems(nullptr);
|
const int n = CountSelectedMediaItems(nullptr);
|
||||||
if (n <= 0) return false;
|
if (n <= 0) return false;
|
||||||
|
bool anyExtent = false;
|
||||||
for (int i = 0; i < n; ++i)
|
for (int i = 0; i < n; ++i)
|
||||||
{
|
{
|
||||||
MediaItem* it = GetSelectedMediaItem(nullptr, i);
|
MediaItem* it = GetSelectedMediaItem(nullptr, i);
|
||||||
if (!it) continue;
|
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);
|
MediaTrack* tr = GetMediaItem_Track(it);
|
||||||
if (!tr) continue;
|
if (!tr) continue;
|
||||||
// Dedup: several selected items can share a track.
|
// Dedup: several selected items can share a track.
|
||||||
@@ -71,6 +87,18 @@ bool collectSelectedItemTracks(ResolvedSource& out)
|
|||||||
return !out.sourceTracks.empty();
|
return !out.sourceTracks.empty();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The header says only that PROJECT_SRATE is IGNORED unless PROJECT_SRATE_USE is set
|
||||||
|
// (SDK ~3064); that it READS 0 on a project which never pinned a rate is an
|
||||||
|
// inference, unverified. Either way a non-positive answer is 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
|
} // namespace
|
||||||
|
|
||||||
// Reads every track's P_RAZOREDITS (SDK header ~2899: space-separated triples of
|
// Reads every track's P_RAZOREDITS (SDK header ~2899: space-separated triples of
|
||||||
@@ -128,10 +156,11 @@ bool collectSelectedTracks(ResolvedSource& out)
|
|||||||
|
|
||||||
bool ResolveScopeSource(CaptureScope scope, ResolvedSource& out, std::string& why)
|
bool ResolveScopeSource(CaptureScope scope, ResolvedSource& out, std::string& why)
|
||||||
{
|
{
|
||||||
|
double itemExtentStart = 0.0, itemExtentEnd = 0.0;
|
||||||
switch (scope)
|
switch (scope)
|
||||||
{
|
{
|
||||||
case CaptureScope::Item:
|
case CaptureScope::Item:
|
||||||
if (!collectSelectedItemTracks(out)) {
|
if (!collectSelectedItemTracks(out, itemExtentStart, itemExtentEnd)) {
|
||||||
why = "select at least one media item"; return false;
|
why = "select at least one media item"; return false;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
@@ -141,7 +170,13 @@ bool ResolveScopeSource(CaptureScope scope, ResolvedSource& out, std::string& wh
|
|||||||
}
|
}
|
||||||
break;
|
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
|
// Empty for an unsaved project (EnumProjects writes an empty .rpp path), which
|
||||||
|
|||||||
@@ -34,6 +34,12 @@ struct ResolvedSource
|
|||||||
double endSeconds = 0.0;
|
double endSeconds = 0.0;
|
||||||
std::vector<MediaTrack*> sourceTracks;
|
std::vector<MediaTrack*> sourceTracks;
|
||||||
std::vector<std::string> trackGuids;
|
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
|
// Reads every track's P_RAZOREDITS and returns the union of parsed track-audio
|
||||||
|
|||||||
@@ -203,14 +203,80 @@ static void testRazorUnionBounds() {
|
|||||||
// --- sourceModeForScope: scope -> render source mode -------------------------
|
// --- sourceModeForScope: scope -> render source mode -------------------------
|
||||||
|
|
||||||
static void testScopeSourceModes() {
|
static void testScopeSourceModes() {
|
||||||
// Each scope drives a distinct render source. Item -> items, Track -> tracks.
|
// Track scope always renders its selected tracks (there is no master scope — to
|
||||||
// (There is no master scope — to capture the master you render a track.) These
|
// capture the master you render a track), whatever the window.
|
||||||
// feed renderSettingsFor and must be supported.
|
CHECK(sourceModeForScope(CaptureScope::Track, true) == SourceMode::SelectedTracks);
|
||||||
CHECK(sourceModeForScope(CaptureScope::Item) == SourceMode::SelectedItems);
|
CHECK(sourceModeForScope(CaptureScope::Track, false) == SourceMode::SelectedTracks);
|
||||||
CHECK(sourceModeForScope(CaptureScope::Track) == 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.
|
// Every scope's source mode is an offline-supported render source.
|
||||||
CHECK(renderSettingsFor(sourceModeForScope(CaptureScope::Item), 1.0).supported);
|
CHECK(renderSettingsFor(sourceModeForScope(CaptureScope::Item, true), 1.0).supported);
|
||||||
CHECK(renderSettingsFor(sourceModeForScope(CaptureScope::Track), 1.0).supported);
|
CHECK(renderSettingsFor(sourceModeForScope(CaptureScope::Track, true), 1.0).supported);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testRangedItemScopeRendersTimeBounded() {
|
||||||
|
// The widening defect pinned at the bit level. A window the item extent does NOT
|
||||||
|
// print must never reach the &32 selected-items source: that source is INFERRED
|
||||||
|
// (unverified — src/core/capture/CLAUDE.md §Gotchas) to take 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testMultiTrackRangedItemRenderIsNamedForRefusal() {
|
||||||
|
// The one shape that cannot land: a ranged item capture (item scope re-sourced to
|
||||||
|
// the selected-tracks render) whose items span more than one track. That source
|
||||||
|
// is INFERRED to render one file per track with no single-file bit available
|
||||||
|
// (unverified; see src/shell/capture/CLAUDE.md §Gotchas for what that inference
|
||||||
|
// rests on), so N stems would collapse onto one render pattern and one track's
|
||||||
|
// audio would land as a successful capture.
|
||||||
|
CHECK(isMultiTrackRangedItemRender(CaptureScope::Item,
|
||||||
|
SourceMode::SelectedTracks, 2));
|
||||||
|
CHECK(isMultiTrackRangedItemRender(CaptureScope::Item,
|
||||||
|
SourceMode::SelectedTracks, 7));
|
||||||
|
|
||||||
|
// One track is the whole point of the re-source — it must still render.
|
||||||
|
CHECK(!isMultiTrackRangedItemRender(CaptureScope::Item,
|
||||||
|
SourceMode::SelectedTracks, 1));
|
||||||
|
CHECK(!isMultiTrackRangedItemRender(CaptureScope::Item,
|
||||||
|
SourceMode::SelectedTracks, 0));
|
||||||
|
|
||||||
|
// A full-extent item capture keeps the selected-items source, whose single-file
|
||||||
|
// bit already sums a multi-track item selection into one file.
|
||||||
|
CHECK(!isMultiTrackRangedItemRender(CaptureScope::Item,
|
||||||
|
SourceMode::SelectedItems, 3));
|
||||||
|
|
||||||
|
// Track scope renders through the same source and is deliberately untouched here
|
||||||
|
// — that per-track output predates the item re-source and is filed in docs/TODO.md.
|
||||||
|
CHECK(!isMultiTrackRangedItemRender(CaptureScope::Track,
|
||||||
|
SourceMode::SelectedTracks, 3));
|
||||||
|
|
||||||
|
// The predicate is reachable from the mapping it guards: the ranged item capture's
|
||||||
|
// own source mode is the one it names.
|
||||||
|
CHECK(isMultiTrackRangedItemRender(CaptureScope::Item,
|
||||||
|
sourceModeForScope(CaptureScope::Item, false), 2));
|
||||||
|
CHECK(!isMultiTrackRangedItemRender(CaptureScope::Item,
|
||||||
|
sourceModeForScope(CaptureScope::Item, true), 2));
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- inferRangeSource: razor-else-time (orthogonal to scope) -----------------
|
// --- inferRangeSource: razor-else-time (orthogonal to scope) -----------------
|
||||||
@@ -262,8 +328,10 @@ static void testTableHasBothScopes() {
|
|||||||
// double-prefix bug, so assert its ABSENCE.
|
// double-prefix bug, so assert its ABSENCE.
|
||||||
CHECK(suffix.rfind("CEREBELLUM_REASAMPLER_", 0) != 0);
|
CHECK(suffix.rfind("CEREBELLUM_REASAMPLER_", 0) != 0);
|
||||||
CHECK(ids.insert(suffix).second); // false if duplicate
|
CHECK(ids.insert(suffix).second); // false if duplicate
|
||||||
// Every scope resolves to a supported offline source.
|
// Every scope resolves to a supported offline source, on BOTH the
|
||||||
CHECK(renderSettingsFor(sourceModeForScope(def.scope), 1.0).supported);
|
// 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::Item) ++item;
|
||||||
if (def.scope == CaptureScope::Track) ++track;
|
if (def.scope == CaptureScope::Track) ++track;
|
||||||
@@ -307,6 +375,8 @@ int main() {
|
|||||||
testParseEmptyAndMalformed();
|
testParseEmptyAndMalformed();
|
||||||
testRazorUnionBounds();
|
testRazorUnionBounds();
|
||||||
testScopeSourceModes();
|
testScopeSourceModes();
|
||||||
|
testRangedItemScopeRendersTimeBounded();
|
||||||
|
testMultiTrackRangedItemRenderIsNamedForRefusal();
|
||||||
testRangeInference();
|
testRangeInference();
|
||||||
testItemScopeBypassesEverythingButTake();
|
testItemScopeBypassesEverythingButTake();
|
||||||
testTrackScopeKeepsSelfBypassesAncestorsAndMaster();
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
// Standalone tests for reasampler::track_topology — no REAPER, no framework.
|
||||||
|
// Covers the direct-child walk over a flat I_FOLDERDEPTH delta list: the set a
|
||||||
|
// ranged item render must silence so a folder parent's children stay out of the
|
||||||
|
// capture.
|
||||||
|
|
||||||
|
#include "../src/core/capture/track_topology.h"
|
||||||
|
|
||||||
|
#include <cstdio>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
static bool sameIndices(const std::vector<int>& got, const std::vector<int>& want) {
|
||||||
|
return got == want;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testFlatProjectHasNoChildren() {
|
||||||
|
// No track opens a folder, so no track has children — including the one asked
|
||||||
|
// about. A flat project must produce an empty silence set, not a stray one.
|
||||||
|
const std::vector<int> depths{0, 0, 0};
|
||||||
|
CHECK(sameIndices(directChildIndices(depths, 0), {}));
|
||||||
|
CHECK(sameIndices(directChildIndices(depths, 1), {}));
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testFolderParentReturnsItsDirectChildren() {
|
||||||
|
// 0: folder parent, 1: child, 2: last child (closes the folder), 3: unrelated.
|
||||||
|
const std::vector<int> depths{1, 0, -1, 0};
|
||||||
|
CHECK(sameIndices(directChildIndices(depths, 0), {1, 2}));
|
||||||
|
// Track 3 sits outside the folder entirely and owns nothing.
|
||||||
|
CHECK(sameIndices(directChildIndices(depths, 3), {}));
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testGrandchildrenAreExcluded() {
|
||||||
|
// 0: outer folder, 1: inner folder (a direct child), 2: grandchild closing the
|
||||||
|
// inner folder, 3: second direct child closing the outer. The grandchild reaches
|
||||||
|
// track 0 only through track 1, so silencing track 1's send covers it — listing
|
||||||
|
// it too would be redundant, and the walk must not.
|
||||||
|
const std::vector<int> depths{1, 1, -1, -1};
|
||||||
|
CHECK(sameIndices(directChildIndices(depths, 0), {1, 3}));
|
||||||
|
// Asked about the inner folder, the grandchild IS the direct child.
|
||||||
|
CHECK(sameIndices(directChildIndices(depths, 1), {2}));
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testMultiLevelCloseEndsTheOuterFolderToo() {
|
||||||
|
// A -2 closes two folders at once (SDK: "last in the innermost and next-innermost
|
||||||
|
// folders"), so track 3 is outside track 0's folder even though no track between
|
||||||
|
// them closed it singly.
|
||||||
|
const std::vector<int> depths{1, 1, -2, 0};
|
||||||
|
CHECK(sameIndices(directChildIndices(depths, 0), {1}));
|
||||||
|
CHECK(sameIndices(directChildIndices(depths, 1), {2}));
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testNonFolderAndOutOfRangeReturnEmpty() {
|
||||||
|
const std::vector<int> depths{1, 0, -1};
|
||||||
|
CHECK(sameIndices(directChildIndices(depths, 1), {})); // a plain child
|
||||||
|
CHECK(sameIndices(directChildIndices(depths, 2), {})); // the folder's last track
|
||||||
|
CHECK(sameIndices(directChildIndices(depths, -1), {})); // no such track
|
||||||
|
CHECK(sameIndices(directChildIndices(depths, 3), {})); // past the end
|
||||||
|
CHECK(sameIndices(directChildIndices({}, 0), {})); // empty project
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testUnterminatedFolderSwallowsTheRest() {
|
||||||
|
// A folder that never closes owns every remaining track, which is how REAPER
|
||||||
|
// reads the same list — the walk must not stop early and leave a child audible.
|
||||||
|
const std::vector<int> depths{1, 0, 0};
|
||||||
|
CHECK(sameIndices(directChildIndices(depths, 0), {1, 2}));
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testSiblingFolderAfterParentClosesIsNotIncluded() {
|
||||||
|
// 0: folder parent, 1: child, 2: last child (closes it). 3: an unrelated sibling
|
||||||
|
// folder that opens AFTER track 0's folder has already closed, 4: its child, 5:
|
||||||
|
// its last child. A walk that fails to stop at track 0's own close would keep
|
||||||
|
// consuming and wrongly pull the sibling's children in too.
|
||||||
|
const std::vector<int> depths{1, 0, -1, 1, 0, -1};
|
||||||
|
CHECK(sameIndices(directChildIndices(depths, 0), {1, 2}));
|
||||||
|
}
|
||||||
|
|
||||||
|
int main() {
|
||||||
|
testFlatProjectHasNoChildren();
|
||||||
|
testFolderParentReturnsItsDirectChildren();
|
||||||
|
testGrandchildrenAreExcluded();
|
||||||
|
testMultiLevelCloseEndsTheOuterFolderToo();
|
||||||
|
testNonFolderAndOutOfRangeReturnEmpty();
|
||||||
|
testUnterminatedFolderSwallowsTheRest();
|
||||||
|
testSiblingFolderAfterParentClosesIsNotIncluded();
|
||||||
|
|
||||||
|
if (g_fail == 0) std::printf("track_topology: all tests passed\n");
|
||||||
|
return g_fail == 0 ? 0 : 1;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user