From 461351ee0254a66438442124c0cba071e66ccb48 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sun, 2 Aug 2026 07:05:33 -0400 Subject: [PATCH] =?UTF-8?q?docs:=20frame=20Phase=20Rho=20=E2=80=94=20rende?= =?UTF-8?q?r=20in=20place?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/product/render-in-place.md | 704 ++++++++++++++++++++++++++++++++ 1 file changed, 704 insertions(+) create mode 100644 docs/product/render-in-place.md diff --git a/docs/product/render-in-place.md b/docs/product/render-in-place.md new file mode 100644 index 0000000..aaf7f1d --- /dev/null +++ b/docs/product/render-in-place.md @@ -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.