docs: frame Phase Rho — render in place
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.
This commit is contained in:
@@ -0,0 +1,704 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user