# src/shell/capture — REAPER-facing capture backends and action bodies

## Scope

Everything that turns a capture request into a rendered file + populated `Sample`,
plus the action bodies that drive capture from REAPER's UI/action list: the two
concrete capture backends (offline render, realtime record), scope/source
resolution, the realtime in-flight state machine, single- and batch-capture
orchestration, insert-to-timeline, provenance stamping, and the shared
`MediaItem*`/`MediaTrack*` GUID-read helpers. Pure decision logic (what counts as
an orphan, how a range maps to capture units, etc.) lives in the corresponding
`core/` modules this shell calls into — this directory is the REAPER API surface
only.

## Invariants

This directory implements, but does not restate, the repo-wide capture precision
invariants (null test, bit-identical repeats, non-destructive, exact bounds,
relative-paths-only) and the load-bearing capture/placement separation — see root
`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`.
- **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. The bounds mode is inside the backend
  that seam calls, for the same reason: a replay must hand its window over exactly
  the way a fresh capture does.
- **The render window travels in the project's own TIME SELECTION**
  (`RENDER_BOUNDSFLAG=2`), so `capture` snapshots and restores that selection on every
  exit path like any other state it borrows. The custom-bounds field floors the window
  to the millisecond and must not come back — why, in
  `src/core/capture/render_settings.h`'s `kRenderBoundsTimeSelection`.
- **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,
  not as capture temporarily leaves it.
- **Realtime capture drives off REAPER's transport across timer ticks** —
  `capture_realtime_shell` cannot block REAPER's UI for the duration of a realtime
  record, so `begin`/`tick`/`abort` are async by construction and the temp-track +
  send recipe lives in the shell, not the pure core.
- **This directory hosts TWO placing paths, and neither is a capture placing
  itself.** `RunInsertSelected` (see `capture_orchestrator` below) places a *bank
  sample*, on demand, which is why it is the deliberate exception to
  capture-never-places. `render_in_place` places a render that never entered the
  bank — the third verb (arrange → arrange, root `CLAUDE.md` §The load-bearing
  principle). Every other entry point here writes only a file + index entry, and no
  capture may ever grow a place step.

## Modules

- `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` — destination-dependent: on `CaptureDestination::Bank` (the default) the `Sample` is handed to `bank_model`; on `ProjectMedia` the file lands outside the bank and the caller (`render_in_place`) discards the returned `Sample`. It also owns the two file-side steps both backends share, in this order: `collapseCapturedFileToMono` (the lossless mono collapse, applied to the landed file) and `stampCaptureSample`, which measures the channel count off that same file so the entry and the audio cannot disagree. And `captureNameFor` — the impure local-clock read the entry points call to build a request's label + stem, kept out of the pure `core/capture/capture_name` composition it feeds.
- `render_bounds_gate` (`shell/capture`) — the exact-bounds verdict on a landed offline render and the refusal's file handling, split off `capture.cpp` on the render-vs-judge seam. Refuses a frame count that is not the window's AND a file whose frames cannot be measured at all (an invalid layout used to skip the gate and land with an unknown channel count). Judges `TailMode::None` only — Auto/Manual add frames by design, and an unmeasurable render still lands under those two (`docs/TODO.md`). Refusal handling is destination-aware (`render_bounds_gate.h`): on `CaptureDestination::Bank`, a refused render is MOVED to `<projectDir>/reasampler_refused/` rather than deleted, so the frames it did print survive for diagnosis while the short-render root cause is open — but a failed move leaves the file sitting unindexed in the bank folder itself, not `reasampler_refused/` (the console message says which happened); the bank never INDEXES it either way. On `CaptureDestination::ProjectMedia` the file is left exactly where the renderer wrote it — no move, no bank folder, no bank language in the message — because that render is the project's own media, not the tool's (`docs/product/render-in-place.md` "Where the file goes").
- `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). Also the one place a source track's NAME is read (`trackName`, via `GetTrackName` — chosen over `P_NAME` because it already answers REAPER's `"Track N"` convention for an unnamed track), landed on `ResolvedSource::trackNames` parallel to `sourceTracks` and composed into the capture's label + stem by the pure `core/capture/capture_name`.
- `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 capture family's deliberate exception to capture-never-places — see the Invariants section above for `render_in_place`, the directory's other placing path, which sits outside the capture family entirely).
- `bake_land` (`shell/capture`) — the EXTENSION's half of the resample chain, the SCAN PASS: scans every open project tab for pending `rsbake_*` requests, lands the ones belonging to the project this session has loaded (via `bake_landing`, below), 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 owns every ext-state read and write in the chain. The per-key verdict itself is NOT this TU's: it is `core/wire`'s pure `classifyBakeScan`, so this shell only enumerates, reads, and applies — counting every verdict into a `wire::BakeScanTally` as it goes, printing `wire::describeBakeKey` for EVERY enumerated key (the only thing that names which key is whose) plus `wire::describeBakeScan` whenever any key went unanswered or any answer's write was not confirmed, in one `ShowConsoleMsg`. It PROVES every write — answer or stale-clear — by reading the key back (`wire::extStateWriteLanded`, whose home is `core/wire/ext_state_read.h`); an answer that did not land is the one no-answer the tally alone cannot show. That proof is three-valued (`wire::BakeWriteProof`): a read-back that overflowed, or a throw AFTER the `SetProjExtState` call, reports Unknown; a throw BEFORE it reports Rejected, because the write is then known not to have been made. Each key is materialized before any answer is written, so no `SetProjExtState` in this action mutates a set the enumerator is still walking. Answers are held UNENCODED until after the pass's single persist, so a landing whose pass never got its persist through is answered as a failure rather than as an `Ok` no reload would honour — `wire::bakeLandingAfterPersist` is the ONE route to a `Banked` landing, and no path here (dedup included) may assign that word itself. The undo block is stack RAII (`UndoBlock`). Both loops are guarded: a throw in the scan still writes the answers already prepared, and a throw in the write-back loop still prints the lines already accumulated — no path through this action can end in a silent console. 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.
- `bake_landing` (`shell/capture`) — landing ONE bake request, split off `bake_land` on the one-request / whole-pass seam; touches no REAPER API at all. It takes the lossless mono collapse on the staged BUFFER (`wav_codec::applyMonoCollapse`, the same predicate the two backends' file-side `collapseCapturedFileToMono` runs) before the hash and before the channel-count read, so the hash, the entry and the written file all come from one buffer — a dead-center render lands 1-channel like any other dead-center capture. Non-mutating `prepareLanding` and mutating `commitLanding` sit under separate catches in `attemptLanding` — a throw before anything was written is a clean refusal, a throw after it is reported as possibly partial. 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" — and a dedup hit still rides the pass's persist, because the entry it points at may be one the same pass just added. 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. It never persists: the pass does that once for its whole batch, which is why no landing may report itself as banked.
- `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.
- `realtime_lifecycle` (`shell/capture`) — the in-flight realtime-capture state machine + globals (Q-W3 hoist): the action starts it, `OnTimer` drives it per tick via `DriveRealtimeCapture` (a single-pointer-test idle fast path — load-bearing hot-path guardrail), `CommitRealtimeResult` lands a finished capture in the bank, `AbortRealtimeCaptureForUnload` tears down cleanly on extension unload.
- `capture_realtime_shell` (`shell/capture`) — the async realtime-record backend surface (Q-W6 split of the former fat `capture.h`): `RealtimeRecordBackend::begin`/`tick`/`abort`, transport-driven across timer ticks (a realtime record cannot block REAPER's UI for its own duration). Deliberately shares NO interface with the offline backend — the lifecycles genuinely differ (the former `ICaptureBackend` interface was deleted in Q-W3, T4-26).
- `capture_realtime_finalize` (`shell/capture`) — the file-side half of the realtime-record shell (Q-W3, T4-08): discovers the file REAPER actually recorded, moves it into the bank, runs the Auto-tail PCM decay-scan trim, and populates the finished `Sample`.
- `render_in_place` (`shell/capture`) — the third verb, arrange → arrange: renders the selected track's output over the resolved range through `renderOffline` with `CaptureDestination::ProjectMedia`, then places the result on a brand-new sibling track at the render window's exact start (unsnapped — this placement IS the null test performed automatically), clones the source's colour and its name through the idempotent `captureTrackName`, and settles both tracks' modes in ONE `UNDO_STATE_ALL` block. Sibling nesting comes from the pure `core/capture/track_topology::siblingPlacement`. The source is tagged Design and the result track + its items are tagged `kArrangeModeId` **explicitly and unconditionally** — never `view.activeModeId()`, and never `untag()`, because the panel's auto-tag detector defers to a membership RECORD. It reads and writes NOTHING in the bank: no `session.bank()`, no `session.book()`, no `recordCreated`, no `bumpBankGeneration`; the `Sample` the backend returns is discarded and its `relativePath` is empty by construction. Traffic is one-way — capture may borrow this render, this placement may never be borrowed back into a capture.
- `insert` — placement via `InsertMedia`. **Conform-to-project-tempo is an explicit opt-in flag, never silent stretching.** The mono collapse needs no change here: `insert.cpp` passes only a path to `InsertMedia`, and REAPER derives the item's channel count from the file itself — a 1-channel WAV yields a mono item for free.
- `provenance_shell` — FX-chain identity queries via `TrackFX_*`/`TakeFX_*` APIs; feeds the pure `provenance` fingerprint builder. Stamps `Sample.provenance` on capture; ambiguous/mixed cases record nothing conservatively.
- `track_guid` — shared `MediaTrack*` → canonical GUID-string formatter; single source of truth for membership keys.
- `item_read` — the ONE place a `MediaItem*` is read for its canonical GUID string (`itemGuid`) and for the durable `P_LANENAME` of the fixed lane it sits on (`itemLaneName`); extracted from previously-duplicated `itemGuid`/`itemLaneName` pairs in `view.cpp` and `bank_panel.cpp` — the item-read analog of `track_guid`'s single `MediaTrack*`→GUID-key formatter. Callers must already know the track is fixed-lane (`I_FREEMODE==2`) before calling `itemLaneName`; the pure `isOnManualLane` predicate handles the non-fixed-lane case separately.

## Gotchas

- This directory's governing precision invariants are the repo-wide capture
  invariants in root `CLAUDE.md`, not a standalone spec block here.
- `capture` and `capture_realtime_shell` deliberately share NO common interface with
  each other (the former `ICaptureBackend` was removed) — do not reintroduce one
  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 `&(4<<16)` 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 EVERY multi-track render through that source
  (`render_settings::isMultiTrackStemRender`) — the ranged item capture and the plain
  track capture alike, each with its own way out
  (`render_settings::multiTrackRefusalMessage`). The refusal is keyed on the render
  SOURCE and not on the scope, so a future caller that reaches `&128` inherits it.
  Re-opening a multi-track track capture needs the DAW check in
  `docs/verify-track-scope-multitrack.md` to come back the other way first.
- **Realtime is the one capture path that accepts a multi-track selection**, and it is
  correct to: its per-source-track sends sum in the one temp track, which is a real mix
  rather than a stem collapse. The offline refusal above does not apply to it.
- **`bake_land`'s panel refresh and its generation bump can disagree after a throw.**
  The refresh rides `bookChanged` outside the guarded scan, while the bump sits inside
  it — so a pass that landed an entry and then threw before reaching the persist block
  repaints the docked panel from the in-memory book without having bumped the bank
  generation, and other open instances stay on the old generation until the next bump.
  Accepted: the panel showing what the pass actually did is the more useful of the two,
  and the next bank mutation reconciles it. Do not "fix" it by moving the refresh inside
  the try — that would trade a stale generation for a stale panel.
