461351ee02
Product notes for rendering a track's output to a new sibling track at the exact render position, source parked in Design mode, bank never touched. Framed as a third verb (arrange->arrange), not an exception to capture/placement separation. Three open forks.
705 lines
41 KiB
Markdown
705 lines
41 KiB
Markdown
# Render in place — product notes
|
||
|
||
Framing, rationale, and design-direction calls behind **Phase Ρ — render a track's
|
||
output to a new sibling track, in the timeline, without touching the bank.** The
|
||
tickable spec lives in `docs/PLAN.md` (§Phase Ρ); the architecture detail belongs in
|
||
`src/shell/capture/CLAUDE.md` and `src/core/capture/CLAUDE.md` once the track lands.
|
||
This doc holds the *why* — the third-verb argument that reconciles this feature with
|
||
the capture/placement separation, the prior art it borrows from, the reuse inventory
|
||
that makes it small, and the handful of decisions the shape actually turns on.
|
||
|
||
Status: framed by product-designer (2026-08-02) from Daniel's direct request the same
|
||
day. **Three [Daniel]-class forks are open** — Ρ-F1 (multi-track), Ρ-F2 (the new
|
||
track's mode when Design is active), Ρ-F3 (tail) — each stated with a recommendation
|
||
in §"Open forks". None of them blocks a dispatch; all three are answerable at
|
||
implementation review if Daniel prefers, but all three are user-visible policy rather
|
||
than implementation detail. Everything else below is a product-designer call with its
|
||
reasoning stated; contradict it in review with an argument, not a preference.
|
||
|
||
---
|
||
|
||
## What it is (and what it is not)
|
||
|
||
**Render in place takes one selected track, renders its output over the current
|
||
range to a file, and drops that file as an item on a brand-new sibling track at the
|
||
exact position it was rendered from — then moves the source track into Design mode.**
|
||
The new track inherits the source's colour and its name with a `Capture ` prefix. The
|
||
bank is never opened, never read, never written.
|
||
|
||
The model Daniel named is REAPER's own *Render selected track time selection to new
|
||
track (stereo) and mute original*. Phase Ρ differs in exactly one respect, and that
|
||
respect is the whole feature: **instead of muting the original, it parks it.** The
|
||
source track goes to Design mode — hidden from the arrange, out of the mix, FX
|
||
offline, CPU reclaimed — and its rendered audio takes its place in the arrangement.
|
||
That is a strictly better disposition than mute, because mute leaves the design
|
||
scaffolding visible and its FX resident; Design mode removes both, reversibly, from
|
||
a snapshot.
|
||
|
||
**It is not a capture.** No `Sample` is minted into any `BankModel`, no index entry is
|
||
added, no file is recorded in the tracking ledger, the bank generation is not bumped,
|
||
and no live ReaSampler 9000 instance reloads. The bank does not change in any way an
|
||
observer could detect.
|
||
|
||
**It is not a freeze.** The source track's FX chain is untouched — not removed, not
|
||
bypassed permanently, not flattened. Design View's park is snapshot-based and fully
|
||
restored on toggle-back (`src/shell/view/CLAUDE.md` §Non-destructive restore), so
|
||
switching to Design brings the source back exactly as it was, FX and routing intact.
|
||
Ableton's *Freeze & Flatten* destroys the device chain; Phase Ρ never does.
|
||
|
||
**It is not a placement of a bank sample.** The insert action and the arrange drop
|
||
both take something already in the bank and put it on the timeline. Phase Ρ's file
|
||
was never in the bank and never will be. The two paths share `InsertMedia` and
|
||
nothing else.
|
||
|
||
---
|
||
|
||
## The third verb — and why the load-bearing principle survives it
|
||
|
||
Root `CLAUDE.md` carries the tool's sharpest rule:
|
||
|
||
> **Capture and placement are separate acts.** Capturing audio writes a file to the
|
||
> bank and adds an index entry. It **never** puts an item in the arrange view. […]
|
||
> Any code path that auto-inserts a capture into the timeline violates the purpose of
|
||
> the tool and **must be rejected in review**.
|
||
|
||
Phase Ρ renders audio, places an item in the arrange, and deliberately does not touch
|
||
the bank. The question is not rhetorical and the answer is not "it's fine because
|
||
Daniel asked for it."
|
||
|
||
**The answer is that the rule is about the bank, not about rendering.** Read the
|
||
sentence again: the object of "capturing" is *the bank* — a file in the bank folder
|
||
plus an index entry. The prohibition attaches to *that act* placing an item. What the
|
||
rule protects is a two-way boundary:
|
||
|
||
- the arrangement must never gain an item as a side effect of a bank gesture, and
|
||
- the bank must never gain a member as a side effect of an arrangement gesture.
|
||
|
||
Phase Ρ crosses neither direction, because **the bank is not a party to it.** The
|
||
system has two verbs today and gains a third:
|
||
|
||
| Verb | Source | Sink | Touches the bank |
|
||
|---|---|---|---|
|
||
| **Capture** (`RunCapture`, batch, realtime, bake, ingest) | arrange / instrument | bank | writes it |
|
||
| **Placement** (`RunInsertSelected`, `performArrangeDrop`) | bank | arrange | reads it |
|
||
| **Render in place** (Phase Ρ) | arrange | arrange | never |
|
||
|
||
Three verbs, three distinct (source, sink) pairs. The bank appears in exactly two of
|
||
them and never on both sides of one. The load-bearing rule is the statement that no
|
||
single verb may have the bank on one side and the arrange on the other *in the wrong
|
||
direction* — and Ρ has the bank on neither side.
|
||
|
||
What Ρ shares with capture is the **render**, not the capture: the same
|
||
`renderOffline` seam, the same `FxBypassGuard`, the same exact-bounds custom time
|
||
window, the same multi-track refusal, the same `RENDER_ADDTOPROJ = 0`. A render is a
|
||
mechanism; a capture is a render *plus* a bank landing. Ρ takes the mechanism and
|
||
declines the landing. That is reuse, not a breach.
|
||
|
||
### The boundary that keeps them from bleeding
|
||
|
||
Four things must stay true. Each is a review-rejectable condition, and three of the
|
||
four are structural rather than remembered:
|
||
|
||
1. **Ρ's shell never names the bank.** `render_in_place.cpp` must not call
|
||
`session.bank()`, `session.book()`, `session.recordCreated()`, or
|
||
`session.bumpBankGeneration()`. The `Sample` that `OfflineRenderBackend::capture`
|
||
returns is discarded, and on the project-media destination its `relativePath` is
|
||
left **empty** — so a Ρ `Sample` is inert by construction and could not be usefully
|
||
added to a bank even by accident.
|
||
2. **Ρ cannot express "write into the bank folder."** The destination reaches the
|
||
backend as a **two-valued enum** (`Bank` / `ProjectMedia`), never as a caller-supplied
|
||
path. There is no string a Ρ caller could pass that lands a file in
|
||
`reasampler_bank/`. This is the single most important structural choice in the
|
||
phase: it makes the boundary a type, not a convention.
|
||
3. **Ρ's file is never recorded as owned.** Prune deletes `(owned ∩ present) −
|
||
referenced` (`src/core/reclaim/CLAUDE.md`), where `owned` comes from the tracking
|
||
ledger. Ρ records nothing, so its file is not prune-eligible — and it lives outside
|
||
the bank folder, so prune's enumeration never sees it either. Two independent
|
||
layers. The symmetry is worth stating plainly: **the tool deletes only what it
|
||
owns, and a render-in-place file belongs to the project, not to the tool.**
|
||
4. **The traffic is one-way.** Ρ may borrow capture's render. **Capture may never
|
||
borrow Ρ's placement.** No capture action grows a "…and place it" option, ever. If
|
||
a future request wants capture-and-place, the answer is "fire the capture action,
|
||
then fire the insert action" — two acts, which is the whole point.
|
||
|
||
**What would count as drift**, stated so a reviewer can name it: a `renderDir` string
|
||
on `CaptureRequest` instead of the enum; a Ρ path that calls `session.bank().add()`;
|
||
a Ρ file recorded via `recordCreated`; a `place` flag added to `CaptureActionDef`; or
|
||
a "Ρ but also add it to the bank" convenience action. Any of those collapses the
|
||
three verbs back into two and the rule stops meaning anything.
|
||
|
||
---
|
||
|
||
## Prior art, and what each one contributes
|
||
|
||
The shape is not novel; the *disposition of the source* is. Named precedents, because
|
||
they anchor the argument better than reasoning does:
|
||
|
||
- **Logic Pro — Bounce in Place.** The idiom Ρ's name borrows. Renders a track's
|
||
output to audio at the same timeline position, on a new track, with the source
|
||
preserved. Confirms that "in place" in DAW usage means *at the same timeline
|
||
position*, not *onto the same track* — which is why the name is right despite Ρ
|
||
creating a new track.
|
||
- **Pro Tools — Commit.** The closest prior art, and the one that validates the mode
|
||
transition. Commit offers four dispositions for the source track: *Hide and Make
|
||
Inactive* (the default), *Make Inactive*, *Delete*, and *Do Nothing*. The default
|
||
is hide-and-deactivate — visually gone and processing gone. That is precisely what
|
||
Design View's park already does (`B_SHOWINTCP=0`, `B_SHOWINMIXER=0`,
|
||
`B_MAINSEND=0`, `I_FXEN=0`, per-FX offline), except that Ρ gets it *reversibly and
|
||
as a membership fact* rather than as a per-track inactive flag. Ρ is Commit with
|
||
a fifth disposition the DAWs do not have — *move to the design bench* — supplied by
|
||
the tool's own mode system.
|
||
([Sound on Sound](https://www.soundonsound.com/techniques/making-commitments),
|
||
[Production Expert](https://www.production-expert.com/production-expert-1/pro-tools-track-commit-vs-track-freeze))
|
||
- **REAPER — Render selected track time selection to new track and mute original.**
|
||
The action Daniel named. Contributes the range semantics (time selection) and the
|
||
new-track placement; Ρ replaces its source disposition and adds colour/name
|
||
cloning.
|
||
- **Ableton Live — Freeze & Flatten.** Contributes a negative: flatten destroys the
|
||
device chain. Ρ explicitly does not, and the Design-mode park is what makes
|
||
preserving it cost nothing at playback.
|
||
|
||
---
|
||
|
||
## What already exists — the reuse inventory
|
||
|
||
Daniel's framing was that the machinery is in place. It substantially is. This table
|
||
is the proof, and it is also the spec's shape: each row names the module that answers
|
||
the need, so the implementation is composition rather than construction.
|
||
|
||
| What Ρ needs | Already answered by |
|
||
|---|---|
|
||
| Resolve the source track + the range (razor-else-time) | `shell/capture/scope_resolve` — `ResolveScopeSource(CaptureScope::Track, …)` |
|
||
| Refuse a multi-track render | `core/capture/render_settings` — `isMultiTrackStemRender` / `multiTrackRefusalMessage`, fired inside `renderOffline` |
|
||
| Render exactly the requested window, wet, at track scope | `shell/capture/capture_orchestrator` — `renderOffline` + `FxBypassGuard` + `RenderTrackSelection` |
|
||
| Never add the render to the project as an item | `shell/capture/capture.cpp` — `RENDER_ADDTOPROJ = 0`, unconditional |
|
||
| Refuse a widened render | `core/capture/render_window::frameCountFor` + the bounds gate in `OfflineRenderBackend::capture` |
|
||
| Snapshot and restore every `RENDER_*` project setting | `ScopedRenderSettings` (RAII) in `capture.cpp` |
|
||
| Force the project to be saved first | the `EnumProjects` / `Main_SaveProject` gate in `OfflineRenderBackend::capture` |
|
||
| Name the render after its source track + a discriminator | `core/capture/capture_name` — `composeCaptureName`, `shell/capture/capture.cpp` — `captureNameFor` |
|
||
| Read the source track's display name (with the `Track N` fallback) | `shell/capture/scope_resolve::trackName` |
|
||
| Collapse a bit-identical stereo render to mono | `core/capture/wav_codec::collapseToMono`, driven by `collapseCapturedFileToMono` |
|
||
| Compute the `InsertMedia` bitmask with the stretch bit provably clear | `core/capture/insert_plan::computeInsertMode` |
|
||
| Place a file at a known track + time, undo-wrapped, selection restored | the recipe in `shell/capture/insert.cpp` / `shell/actions/arrange_drop_win.cpp` |
|
||
| Move a track into Design and reapply the active mode | `core/view` `MembershipIndex::tag` + `shell/view/view.h` `applyMode` / `mintManagedLanes` |
|
||
| Persist the view model | `ReaSamplerSession::saveToActiveProject()` (the `persistViewState` pattern in `design_view_actions.cpp`) |
|
||
| Register one more bindable action | `shell/actions/action_registry` — one `ActionTableRow` in `main.cpp`'s table |
|
||
| Pure folder arithmetic over the flat `I_FOLDERDEPTH` delta list | `core/capture/track_topology` (extended — see §"The new track") |
|
||
|
||
**What genuinely does not exist**, and why nothing already there stretches to cover
|
||
it — three small pure additions and one bounded seam:
|
||
|
||
1. **A render destination that is not the bank.** `OfflineRenderBackend::capture`
|
||
derives its output path from `deriveBankPaths(projectDir, …)` unconditionally
|
||
(`capture.cpp:417`) and points `RENDER_FILE` at the bank folder. Nothing about
|
||
that is parameterized. The alternative — render into the bank and then move the
|
||
file out — was rejected: it puts a transient, unindexed, unowned file inside the
|
||
folder prune enumerates, which is exactly the file class the ownership rule exists
|
||
to reason about, and it would make the bank folder momentarily lie about its
|
||
contents. **Seam:** a `CaptureDestination { Bank, ProjectMedia }` field on
|
||
`CaptureRequest` (defaulting to `Bank`), resolved by the backend *after* its own
|
||
save gate, plus a `RenderPaths deriveRenderPaths(absoluteDir, baseName, uniqueTag)`
|
||
sibling in `capture_paths` that `deriveBankPaths` is then expressed in terms of, so
|
||
the file-stem spelling keeps one owner.
|
||
2. **The absolute path of the rendered file, returned.** `CaptureResult` carries only
|
||
`sample.relativePath`, which Ρ deliberately leaves empty. One new field,
|
||
`CaptureResult::absolutePath`, set on the Ok path.
|
||
3. **Where a sibling track goes, in folder terms.** Genuinely new, genuinely
|
||
necessary, and genuinely small — see §"The new track".
|
||
4. **The idempotent `Capture ` prefix.** Six lines in `core/capture/capture_name`.
|
||
|
||
Everything else is composition. No new directory, no new backend, no new interface,
|
||
no new persisted state.
|
||
|
||
---
|
||
|
||
## The render — scope, range, refusal
|
||
|
||
**Scope is Track**, always. `CaptureScope::Track` means the render hears the item/take
|
||
FX plus the selected track's own track FX, with every ancestor and the master
|
||
neutralized to unity — no FX, no fader, no pan/width/law colouring
|
||
(`fxBypassPlanFor`, `FxBypassGuard`). That is exactly right for a drop-in
|
||
replacement: what the render contains is *the track's own contribution to its
|
||
parent*, which is what the new sibling track must reproduce when it feeds the same
|
||
parent.
|
||
|
||
**The range is razor-else-time selection**, resolved by `ResolveScopeSource` — the
|
||
same rule every other capture action already obeys. Razor wins when present; the
|
||
razor union's bounds are the window. If neither a razor area nor a time selection is
|
||
present, the action refuses with the reason `resolveRange` already produces. **Item
|
||
extent is not a fallback**, and should not become one: item extent is item scope's
|
||
concern, and a track render bounded by whichever items happen to be selected is a
|
||
different and much less predictable verb.
|
||
|
||
**Multi-track selections are refused**, inherited rather than re-implemented.
|
||
`renderOffline` fires `isMultiTrackStemRender` before touching anything, keyed on the
|
||
render *source* (`SelectedTracks`, which track scope always uses), so any selection of
|
||
more than one track refuses with `multiTrackRefusalMessage(CaptureScope::Track)`
|
||
before a single project setting is written. Ρ inherits this for free and adds no
|
||
check of its own. See fork Ρ-F1 for the alternative.
|
||
|
||
**Tail follows the panel setting** — None / Auto / Manual, read from
|
||
`bankPanelTailSetting()` like every other capture path. Under Auto or Manual the
|
||
rendered file is longer than the requested window by design (the chain's decay rings
|
||
past the range end), so the placed item is correspondingly longer than the source
|
||
window. That is the musically correct answer for a design chain with reverb on it,
|
||
and forcing None would be Ρ inventing a policy the rest of the tool does not have.
|
||
Two consequences to state rather than discover: the exact-bounds gate in
|
||
`OfflineRenderBackend::capture` runs **only** under `TailMode::None`, so Auto/Manual
|
||
renders are unguarded against widening (inherited, not introduced); and the placed
|
||
item's *start* is exact in every mode, because a tail is added at the end only. See
|
||
fork Ρ-F3.
|
||
|
||
**Mono collapse applies**, unchanged. A render whose channels are bit-identical
|
||
collapses losslessly to one channel and REAPER derives a mono item from the file
|
||
(`insert.cpp` passes only a path). Daniel's Ψ.6 ask named "mono arrange items"
|
||
explicitly, so this is the intended outcome, not a side effect. **But note what Ρ
|
||
changes about the risk:** root `CLAUDE.md` already flags, as `[verify — DAW]`,
|
||
whether REAPER sums a 1-channel item on a stereo track at the same unity gain as a
|
||
dual-mono 2-channel item. Until Ρ, that property was unverified but not load-bearing
|
||
— nothing in the tool placed a collapsed capture automatically. **Ρ is the first path
|
||
where a collapsed render is placed into the mix by the tool itself,** which promotes
|
||
that question from a footnote to a verification obligation on this phase.
|
||
|
||
---
|
||
|
||
## Where the file goes
|
||
|
||
**The project's recording path** — `GetProjectPathEx(proj, buf, sz)` (SDK header
|
||
2550), which the header's own `RECORD_PATH` entry names as the way to get the
|
||
*effective* path when `RECORD_PATH` is blank or relative (header 3102).
|
||
|
||
Why there rather than a dedicated `reasampler_renders/` folder: because the file is
|
||
**the project's media, not the tool's.** REAPER's own render-to-new-track, apply-FX,
|
||
and freeze glue actions all write into the recording path; *Clean current project
|
||
directory* and *Save project as… with copy of media* both understand it. A file in
|
||
the recording path is managed by REAPER's project-media machinery, which is exactly
|
||
the machinery that should own it. A `reasampler_renders/` folder would be marginally
|
||
more findable and would make ReaSampler the apparent owner of files it explicitly
|
||
does not own — the wrong trade.
|
||
|
||
The relative-paths-only invariant is untouched: it binds the persisted `BankIndex`,
|
||
and Ρ writes nothing to any index. REAPER stores the item's source path in the `.rpp`
|
||
by its own rules.
|
||
|
||
---
|
||
|
||
## Placement — exactly
|
||
|
||
The item lands at **`src.startSeconds`**, the render window's start, unrounded and
|
||
**unsnapped**.
|
||
|
||
Unsnapped is the load-bearing word. `performArrangeDrop` runs its drop time through
|
||
`SnapToGrid` because a hand drop wants snapping; Ρ must not, because a snapped
|
||
placement would move the audio off the sample-accurate position it was rendered from
|
||
and break the property the whole tool is built on. **Ρ's placement is the null test
|
||
performed automatically:** an offline render of a range, re-inserted at its source
|
||
position, nulls to silence against the source — root `CLAUDE.md` calls that the
|
||
tool's trust anchor. Ρ *is* that gesture, made a workflow. If Ρ's placement is not
|
||
sample-exact, Ρ is broken, and the way you find out is by soloing the two tracks with
|
||
one polarity-inverted.
|
||
|
||
The recipe is `insert.cpp`'s, verbatim, with the bank lookup removed:
|
||
snapshot the cursor → `SetOnlyTrackSelected(newTrack)` → `SetEditCurPos(startSeconds,
|
||
false, false)` → `InsertMedia(absolutePath, computeInsertMode(InsertOptions{}))` →
|
||
restore the cursor. `InsertOptions{}` defaults give native length, no tempo conform,
|
||
and `insert_plan` guarantees the &4 stretch-to-time-selection bit is never set — so
|
||
"do not silently time-stretch on insert" holds by construction. Ρ must never offer a
|
||
conform variant: a conform would defeat the exact placement it exists to produce.
|
||
|
||
**Selection afterwards is a deliberate divergence.** Every other placing path
|
||
restores the caller's track selection. Ρ leaves **the new track selected, alone.**
|
||
The reason is specific: in the headline case the source track is being parked out of
|
||
sight in the same gesture, so restoring the selection would leave the user selecting
|
||
an invisible track. The new track is the workflow's next subject; select it. The edit
|
||
cursor *is* restored, since nothing about Ρ argues for moving it.
|
||
|
||
---
|
||
|
||
## The new track — index, folder, colour, name
|
||
|
||
### Index and folder — the one piece of genuinely new arithmetic
|
||
|
||
"Sibling" is easy to say and has three cases. Getting it wrong is audible, not
|
||
cosmetic, which is why this is the one place Ρ adds a real (small) pure function
|
||
rather than composing.
|
||
|
||
The naive answer — insert at `sourceIndex + 1` — is wrong twice:
|
||
|
||
- **Source is a folder parent** (`I_FOLDERDEPTH >= 1`). Inserting immediately after
|
||
it makes the new track the folder's **first child**, so the rendered audio is
|
||
summed back into the folder and runs through the parent's FX and fader a second
|
||
time. Track scope already put the parent's own FX and fader *into* the render, so
|
||
this double-processes audibly.
|
||
- **Source is the last track in its folder** (`I_FOLDERDEPTH <= -1`). The source
|
||
carries the folder's closing delta, so inserting after it lands the new track
|
||
**outside** the folder — the audio then bypasses the folder bus entirely and the
|
||
drop-in replacement is silently wrong in the other direction.
|
||
|
||
The correct rule is one computation in absolute nesting levels, over the same flat
|
||
`I_FOLDERDEPTH` delta list `track_topology::directChildIndices` already prefix-sums.
|
||
Given `depth[i]` for every track and `level[0] = 0`, `level[i+1] = level[i] +
|
||
depth[i]` (and `level[count] = 0` for a well-formed project):
|
||
|
||
1. `L = level[srcIdx]` — the source's own nesting level.
|
||
2. **Insert position** `p`: if `depth[srcIdx] >= 1` (folder parent), `p` = the first
|
||
`j > srcIdx` with `level[j] == L` — i.e. immediately after the whole folder, at
|
||
the source's own level; `count` if none. Otherwise `p = srcIdx + 1`.
|
||
3. **Two folder-depth writes**, and only two. With `b = p - 1` (the track that will
|
||
precede the new one) and `Lp = level[p]` (the level the track currently at `p`
|
||
sits at, `0` at end-of-project): set `depth[b] = L - level[b]`, and set the new
|
||
track's `depth = Lp - L`.
|
||
|
||
The total of all deltas is preserved, so nothing downstream of the insertion shifts.
|
||
Checked against every case:
|
||
|
||
| Case | `depth[b]` after | new track `depth` | Result |
|
||
|---|---|---|---|
|
||
| Normal track, mid-folder or top level | unchanged (`0`) | `0` | inserted directly below, same level |
|
||
| Last track in a folder (`-1`) | `0` | `-1` | new track becomes the folder's last member |
|
||
| Last in two folders (`-2`) | `0` | `-2` | closing delta moves to the new track intact |
|
||
| Folder parent | unchanged | `0` | new track lands after the whole folder, at the parent's level |
|
||
| Last track in the project | unchanged | `0` or the source's close | consistent, sums to zero |
|
||
|
||
That is roughly thirty lines, fully unit-testable with no DAW, and it belongs beside
|
||
`directChildIndices` in `core/capture/track_topology` — same input, same arithmetic,
|
||
same file. A malformed project whose deltas do not sum to zero should clamp rather
|
||
than assert; the failure mode is a track at the wrong nesting level, never a crash.
|
||
|
||
Creation is `InsertTrackInProject(proj, p, /*flags=*/0)` (SDK header 3954), then
|
||
`GetTrack(proj, p)` (3501) to obtain the handle. **`flags = 0`, not `1`:** the header
|
||
states `flags&1` adds default envelopes/FX, and a Ρ track must be bare — the FX are
|
||
already baked into the audio, and a default chain would process the render a second
|
||
time.
|
||
|
||
### Colour
|
||
|
||
`SetMediaTrackInfo_Value(newTrack, "I_CUSTOMCOLOR", (double)GetTrackColor(source))`.
|
||
`GetTrackColor` (3517) returns the custom colour already OR'd with `0x1000000`, or
|
||
`0` when the track has no colour set; `I_CUSTOMCOLOR` (2942) treats a value without
|
||
that bit as "not used." So the same single line clones a colour *and* clones the
|
||
absence of one, with no branch.
|
||
|
||
### Name
|
||
|
||
`"Capture " + sourceName`, where `sourceName` is `scope_resolve::trackName(source)` —
|
||
`GetTrackName` (3629), which already answers REAPER's `Track N` convention for an
|
||
unnamed track. An unnamed track 7 therefore yields `Capture Track 7`, which is a real,
|
||
deterministic, identifiable name; this is Ψ-W2-T1's precedent applied unchanged.
|
||
Written with `GetSetMediaTrackInfo_String(newTrack, "P_NAME", buf, true)` (2997).
|
||
|
||
**The prefix is idempotent — it never stacks.** If the source name already begins with
|
||
`"Capture "`, the new name is the source name **verbatim**. So rendering `MONEY`
|
||
gives `Capture MONEY`, and rendering `Capture MONEY` gives `Capture MONEY` again, not
|
||
`Capture Capture MONEY`.
|
||
|
||
The alternative — a counter suffix, `Capture MONEY 2` — is rejected. REAPER does not
|
||
uniquify track names either, duplicate track names are ordinary and harmless, and a
|
||
counter is a treadmill that has to be maintained forever. What actually distinguishes
|
||
two renders of the same source is their position in the track list and the item on
|
||
each; the name's job is to say *what this is*, and it says that correctly the first
|
||
time. Making the operation a fixed point is worth more than distinguishability here.
|
||
|
||
This is one pure function in `core/capture/capture_name` — `captureTrackName(sourceName)`
|
||
— tested for the plain case, the already-prefixed case, the empty-source case, and
|
||
the `Track N` case. The prefix string is a display convention, not a persisted key:
|
||
unlike `kManagedLanePrefix` or an action-id suffix, changing it later strands nothing.
|
||
|
||
---
|
||
|
||
## Mode transitions — resolving "stays/goes"
|
||
|
||
Daniel's phrasing was *"the source track stays/goes to design mode, and the resulting
|
||
new sibling track […] stays in whatever mode was active when the action was run."*
|
||
Both halves resolve into the shipped membership model without inventing anything.
|
||
|
||
**Source track: unconditionally a Design member afterwards.**
|
||
`membership().tag(sourceGuid, kDesignModeId)` covers both readings in one call —
|
||
`tag` replaces any prior single-mode membership, so a source already in Design
|
||
*stays* (no observable change) and a source in Arrange or untagged *goes*. This is
|
||
exactly what the shipped `VIEW_TAG_DESIGN` action does to a selection; Ρ performs it
|
||
on one track as part of a larger gesture.
|
||
|
||
Two inherited behaviours to state rather than fight:
|
||
|
||
- **Show-both on the source is not cleared.** Show-both is the user's explicit "pin
|
||
this visible across modes" flag. Ρ tagging a source into Design must not silently
|
||
unpin it; a show-both source stays visible in both stances, which is what the user
|
||
asked for.
|
||
- **A folder-parent source is not hidden by tagging it.** Parents are derived, never
|
||
tagged: a parent is visible in every mode any descendant leaf is visible in
|
||
(`core/view/CLAUDE.md`). Tagging a folder parent Design sets its *own* membership
|
||
but leaves it visible in Arrange as long as any child is an Arrange member. This is
|
||
a limitation of the shipped model that the existing tag action shares exactly; Ρ
|
||
inherits it. Do **not** invent a cascade that tags the children — that changes the
|
||
membership model to make one feature convenient.
|
||
|
||
**New track: tagged to the mode that was active when the action fired**,
|
||
synchronously and explicitly — `membership().tag(newTrackGuid, view.activeModeId())`.
|
||
|
||
**This must happen before `applyMode` reapplies, and that ordering is load-bearing.**
|
||
The auto-tag poller (`panel_input::detectNewContent`, over `guid_diff::GuidBaseline`)
|
||
would tag the new track on its next tick, and would reach the same answer — but the
|
||
reapply inside Ρ's own gesture runs first. An untagged track is an Arrange member by
|
||
default, so if the active mode is Design and Ρ reapplies before tagging, the reapply
|
||
**parks the brand-new capture track** — hidden, out of the mix — and it stays parked
|
||
until the next mode switch. Tagging explicitly, first, closes that window; the
|
||
poller's later observation is then idempotent (it re-derives the same mode).
|
||
|
||
The resulting behaviour, stated completely:
|
||
|
||
| Active mode when fired | Source afterwards | New track afterwards | What the user sees |
|
||
|---|---|---|---|
|
||
| **Arrange** | Design — parked, hidden, FX offline | Arrange — visible, in the mix | The headline case. The design chain vanishes from the arrangement and its audio takes its place, at the same position, same colour, named after it. |
|
||
| **Design** | Design — visible | Design — visible | Both on the bench, side by side, for A/B. Neither reaches Arrange. |
|
||
|
||
The Design-active case is coherent — you are iterating on the bench and want the
|
||
render beside its source — but it means firing Ρ from Design never puts anything into
|
||
the arrangement. That follows directly from Daniel's stated rule and is a genuinely
|
||
useful second behaviour, not a defect. It is also the subject of fork Ρ-F2.
|
||
|
||
**Lane minting runs**, via `mintManagedLanes(view, nullptr)` before the reapply, on
|
||
the same path `doMoveItems` already uses — so a track that ends up carrying content
|
||
for two modes splits into managed lanes exactly as it would from any other membership
|
||
change. Ρ adds no lane rule of its own.
|
||
|
||
---
|
||
|
||
## Undo
|
||
|
||
**One undo block** (`Undo_BeginBlock2` / `Undo_EndBlock2` with `UNDO_STATE_ALL`, i.e.
|
||
`-1`), opened before the track is created and closed after the mode reapply — the
|
||
same shape `insert.cpp` and `performArrangeDrop` already use. The render itself sits
|
||
*outside* the block: `renderOffline` mutates only `RENDER_*` project settings, which
|
||
it snapshots and restores by RAII, and writes a file. Nothing there is undoable and
|
||
nothing there should be in the undo history.
|
||
|
||
What one Ctrl-Z therefore restores: the new track is gone, its item with it, the
|
||
source track's folder-depth write is reverted, and the track selection is back.
|
||
|
||
Three residuals, all inherited and all honest:
|
||
|
||
1. **The rendered file survives.** REAPER's undo does not delete files, prune is the
|
||
exclusive deletion authority in this system, and Ρ's file is not even prune-
|
||
eligible. An undone render leaves an orphan `.wav` in the project's recording
|
||
path — precisely what REAPER's own render and record actions do. Not a defect.
|
||
2. **The source stays tagged Design.** REAPER's undo restores live track state but
|
||
does not roll back the view model's membership index or active mode — documented
|
||
in `src/shell/view/CLAUDE.md` §Gotchas, where `snapshots_` already carries the same
|
||
split. The way out is the existing *tag selected tracks → Arrange* action. Do not
|
||
build a compensating mechanism for one feature; the model-vs-undo split is a
|
||
phase-D-scale question, not Ρ's.
|
||
3. **A membership entry for the deleted track's GUID lingers**, harmlessly:
|
||
`ViewModeModel::reconcile(liveGuids)` prunes unknown GUIDs on its next pass.
|
||
|
||
**Persist runs after the block closes, not inside it** — the ordering
|
||
`design_view_actions::doMoveItems` already documents, because `persistViewState` may
|
||
raise a Save-As dialog and a modal dialog must not sit inside an open undo block.
|
||
|
||
---
|
||
|
||
## The action
|
||
|
||
**Command-id suffix: `RENDER_TRACK_IN_PLACE`.** FOREVER-STABLE per channel
|
||
(`channelCommandId` composes `CEREBELLUM_REASAMPLER_` / `CEREBELLUM_REASAMPLER_BETA_`
|
||
in front of it), so this string can never change once shipped — user keybindings key
|
||
off the composed id.
|
||
|
||
Chosen deliberately as a **new verb family**, not a member of `CAPTURE_*`. The id is
|
||
permanent and it is the most durable statement the codebase makes about which pillar
|
||
a feature belongs to; filing this under `CAPTURE_` would encode the exact confusion
|
||
the third-verb argument exists to prevent. `RENDER_*` also leaves room for a future
|
||
`RENDER_ITEMS_IN_PLACE` without renaming anything.
|
||
|
||
**Actions-list phrase: `"render selected track to a new track (source moves to
|
||
Design)"`**, which REAPER shows as *ReaSampler: render selected track to a new track
|
||
(source moves to Design)*. Long, but the parenthetical is not decoration — a user
|
||
binding this to a key must know the source is about to disappear from the arrangement
|
||
before they press it, not after. The existing family already carries parentheticals of
|
||
this weight (*insert selected sample at edit cursor (conform to tempo)*).
|
||
|
||
Registration is **one `ActionTableRow`** in `main.cpp`'s `buildMainActionTable()` — the
|
||
Q-W6 data-driven table drives registration, `hookcommand` dispatch, and the unload
|
||
mirror-unregister from that one row. Main section only; no `custom_action` /
|
||
`hookcommand2` second registration is needed.
|
||
|
||
**Feedback:** silent on success (the new track is the feedback), `ShowConsoleMsg` on
|
||
every refusal, carrying the reason `resolveRange` / `renderOffline` already produced.
|
||
This matches `RunInsertSelected` exactly.
|
||
|
||
---
|
||
|
||
## What Phase Ρ explicitly is NOT
|
||
|
||
Stated as sharply as the goals, because a small phase stays small only if its edges
|
||
are named:
|
||
|
||
- **Not a bank capture, in any form.** No index entry, no ledger record, no
|
||
generation bump, no instance reload.
|
||
- **Not multi-track** (this phase — see Ρ-F1). One selected track per fire; more than
|
||
one refuses.
|
||
- **Not item-scoped.** No `RENDER_ITEMS_IN_PLACE`, no item-extent range fallback. The
|
||
seam is left open by the id family; the feature is not built.
|
||
- **Not a tempo-conforming insert.** No conform variant, ever — a conform would
|
||
destroy the exact placement the feature exists to produce.
|
||
- **Not a mute, not a delete, not a freeze.** The source keeps its items, its FX, its
|
||
routing and its automation. It moves stance; it loses nothing.
|
||
- **Not a source-track cascade.** Rendering a folder parent does not tag its children,
|
||
does not restructure the folder, and does not touch anything but the two
|
||
folder-depth values the insertion arithmetic requires.
|
||
- **Not a new persisted state.** Membership writes go into the existing `"reasampler"`
|
||
view section. Ρ adds no key, no version rung, no wire format.
|
||
- **Not a new directory.** Three small pure additions to existing `core/capture`
|
||
modules, one new shell TU in `shell/capture`, one row in `main.cpp`.
|
||
|
||
---
|
||
|
||
## Invariant amendments this phase owns
|
||
|
||
Two statements in the tree become false the moment Ρ lands, and amending them is a
|
||
**deliverable of the track**, not a follow-up — the precedent is Phase Ψ, where three
|
||
such amendments were carried as acceptance criteria of the tracks that broke them. A
|
||
track that lands Ρ without these reads as an invariant breach in review.
|
||
|
||
1. **`src/shell/capture/CLAUDE.md` §Invariants** — *"`RunInsertSelected` is the one
|
||
deliberate exception to capture-never-places … every other capture entry point
|
||
writes only a file + index entry."* Ρ adds a second placing path in this
|
||
directory. The amended form must say that this directory now hosts two placing
|
||
paths and state the discriminator: `RunInsertSelected` places a *bank sample*;
|
||
`render_in_place` places a render that never entered the bank. Neither is a
|
||
capture placing itself.
|
||
2. **`src/shell/actions/CLAUDE.md` §Invariants** — *"`arrange_drop_win` is the only
|
||
timeline-placing shell in this directory."* Strictly this stays true if Ρ's shell
|
||
lives in `shell/capture/`, but the sentence reads as a claim about the system.
|
||
Amend it to be explicit that it scopes to *this directory*, and cross-reference the
|
||
third verb.
|
||
|
||
Root `CLAUDE.md` §"The load-bearing principle" should gain **one sentence**, not a
|
||
rewrite: that a render which never enters the bank and never leaves it is a third
|
||
verb outside the rule, with the two-way boundary spelled out. The rule's force must
|
||
not be diluted — it is what keeps the tool honest — so the amendment names the
|
||
exception precisely rather than softening the prohibition.
|
||
|
||
---
|
||
|
||
## Where it lives
|
||
|
||
**Pure** — three additions, all to existing modules with existing test targets, no new
|
||
directory:
|
||
|
||
- `core/capture/track_topology` — the sibling-placement arithmetic
|
||
(`siblingPlacement(depths, srcIdx) -> { insertIndex, precedingDepth, newDepth }`).
|
||
Same input list, same prefix-sum, same file as `directChildIndices`.
|
||
- `core/capture/capture_name` — `captureTrackName(sourceName)`, the idempotent prefix.
|
||
- `core/capture/capture_paths` — `RenderPaths` + `deriveRenderPaths(absoluteDir,
|
||
baseName, uniqueTag)`, with `deriveBankPaths` re-expressed over it so the stem
|
||
spelling keeps one owner (`bankRelativeForName` already depends on that being true).
|
||
|
||
**Shell** — one new TU plus one bounded edit:
|
||
|
||
- `shell/capture/render_in_place.{h,cpp}` — the action body. It lives in
|
||
`shell/capture/` rather than `shell/actions/` because it composes `renderOffline`
|
||
and `ResolveScopeSource` and is genuinely a render path, not a skin over one; the
|
||
directory's own CLAUDE.md says action *bodies* belong here and that `shell/actions`
|
||
only skins mutation logic owned elsewhere.
|
||
- `shell/capture/capture.cpp` — the destination branch (~6 lines at the path
|
||
derivation) and `CaptureResult::absolutePath`. **No behavioural change on the bank
|
||
path**: the enum defaults to `Bank`, and the bank branch must be byte-identical to
|
||
today.
|
||
- `src/app/main.cpp` — one `ActionTableRow`.
|
||
|
||
**Performance posture:** every surface is cold — one gesture, once. None of the named
|
||
hot paths (peaks envelope compute, audition, the realtime-capture tick's
|
||
single-pointer-test idle fast path, the instrument's `process()`) is touched, and no
|
||
guardrail applies beyond the general one.
|
||
|
||
---
|
||
|
||
## DAW-verification obligations
|
||
|
||
Following the plan's convention, stated up front so they are an obligation rather
|
||
than a discovery. Nothing in Ρ is unit-testable past the pure functions.
|
||
|
||
- **The null test on Ρ's own output** — render a track over a range, then
|
||
polarity-invert the source against the new track and confirm silence. This is the
|
||
phase's trust anchor and the single most important check.
|
||
- **The three folder cases** — a normal mid-folder track, a last-in-folder track, and
|
||
a folder parent — each rendered, each confirming the new track's nesting level and
|
||
that the render feeds (or bypasses) the folder bus correctly. `[verify — DAW]`
|
||
whether `InsertTrackInProject` at index `p` combined with the two `I_FOLDERDEPTH`
|
||
writes settles without an intermediate `TrackList_AdjustWindows(false)` (header
|
||
7735; the note at 2721 says some attribute writes need a manual panel update, and
|
||
the `isMinor` semantics are undocumented).
|
||
- **The collapsed-mono placement** — render a dead-centre source, confirm the item is
|
||
mono, and confirm it sums at the same level as the stereo source did. This is root
|
||
`CLAUDE.md`'s existing `[verify — DAW]` on mono-item-on-stereo-track summing,
|
||
promoted to load-bearing by Ρ.
|
||
- **Both mode transitions** — fired from Arrange (source parks, new track visible) and
|
||
fired from Design (both visible, neither in Arrange) — plus the ordering check that
|
||
the new track is never momentarily parked.
|
||
- **Undo** — one Ctrl-Z removes the track and item and restores the folder depth; the
|
||
file survives; the source stays tagged Design.
|
||
- **The name and colour clone**, including a second run over an already-prefixed track
|
||
(must not stack) and an unnamed source (must read `Capture Track N`).
|
||
- **`GetProjectPathEx` on a project saved in a folder with a non-default recording
|
||
path**, confirming the render lands where the project's media lives.
|
||
|
||
---
|
||
|
||
## Open forks — Daniel's
|
||
|
||
### Ρ-F1 — Multi-track: inherit the refusal, or render each selected track?
|
||
|
||
**Recommendation: inherit the refusal.** One selected track per fire; more than one
|
||
refuses with the message that already exists. Daniel's phrasing was singular ("the
|
||
source track"), and it costs zero code.
|
||
|
||
**The counter-argument is real**, which is why this is a fork rather than a call.
|
||
REAPER's model action operates on the selection, and the multi-track *stem-collapse*
|
||
hazard does not actually apply here: Ρ could loop, running `renderOffline` once per
|
||
selected track with a one-track source each time, and every individual render would
|
||
be single-track and safe. What it would cost is not the render but the bookkeeping —
|
||
each insertion shifts the indices of every later track, so the folder arithmetic must
|
||
be re-derived per iteration; and the undo label, the partial-failure story ("three of
|
||
five rendered"), and the selection-afterwards rule all have to be answered.
|
||
|
||
If Daniel wants multi-track, the honest shape is a **second wave**, not a bigger
|
||
first one — the single-track verb is a strict prerequisite either way, and the design
|
||
already leaves room (the placement function takes an index and returns a plan, so a
|
||
loop just re-reads the depth list each pass).
|
||
|
||
### Ρ-F2 — When Design is the active mode, where does the new track go?
|
||
|
||
**Recommendation: to Design, as Daniel stated** — the new track takes whatever mode
|
||
was active. Implemented as written.
|
||
|
||
**The alternative is defensible**: make Ρ *absolute* rather than mode-relative — the
|
||
source always goes to Design, the capture always goes to Arrange, regardless of which
|
||
stance you fired from. That reading makes Ρ mean "promote this design work into the
|
||
arrangement" in every context, which is arguably the verb's actual purpose, and it
|
||
means the action does the same thing wherever you are.
|
||
|
||
The relative rule wins on Daniel's explicit words and on one real use: firing Ρ from
|
||
Design to get an A/B of a chain against its own render, on the bench, without either
|
||
touching the arrangement. That is a genuinely useful thing the absolute rule cannot
|
||
express. But it does mean a user in Design mode who expects to have "committed
|
||
something to the arrangement" has not. **One confirm.**
|
||
|
||
### Ρ-F3 — Tail: follow the panel setting, or force None?
|
||
|
||
**Recommendation: follow the panel setting** (None / Auto / Manual), for consistency
|
||
with every other render path and because a decaying design chain wants its tail when
|
||
its source is about to be silenced.
|
||
|
||
**The counter:** Ρ's pitch is a drop-in replacement for the source, and under Auto or
|
||
Manual the placed item is longer than the window it replaces — which is correct for
|
||
reverb and wrong for a section you intend to butt against the next one. Forcing None
|
||
would also keep the exact-bounds gate active on every Ρ render, since that gate runs
|
||
only under `TailMode::None`.
|
||
|
||
A third option exists and is worth naming: follow the panel setting but **run the
|
||
bounds gate's start-alignment check regardless of tail mode**, since a tail only ever
|
||
extends the end. That gets both properties at the cost of a small change to the gate's
|
||
condition, and it is the option I would take if Daniel wants the guard without losing
|
||
the tail. Not recommended by default only because it edits a shared gate for one
|
||
caller's benefit.
|