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.
41 KiB
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:
- Ρ's shell never names the bank.
render_in_place.cppmust not callsession.bank(),session.book(),session.recordCreated(), orsession.bumpBankGeneration(). TheSamplethatOfflineRenderBackend::capturereturns is discarded, and on the project-media destination itsrelativePathis left empty — so a ΡSampleis inert by construction and could not be usefully added to a bank even by accident. - Ρ 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 inreasampler_bank/. This is the single most important structural choice in the phase: it makes the boundary a type, not a convention. - Ρ's file is never recorded as owned. Prune deletes
(owned ∩ present) − referenced(src/core/reclaim/CLAUDE.md), whereownedcomes 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. - 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, Production Expert) - 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:
- A render destination that is not the bank.
OfflineRenderBackend::capturederives its output path fromderiveBankPaths(projectDir, …)unconditionally (capture.cpp:417) and pointsRENDER_FILEat 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: aCaptureDestination { Bank, ProjectMedia }field onCaptureRequest(defaulting toBank), resolved by the backend after its own save gate, plus aRenderPaths deriveRenderPaths(absoluteDir, baseName, uniqueTag)sibling incapture_pathsthatderiveBankPathsis then expressed in terms of, so the file-stem spelling keeps one owner. - The absolute path of the rendered file, returned.
CaptureResultcarries onlysample.relativePath, which Ρ deliberately leaves empty. One new field,CaptureResult::absolutePath, set on the Ok path. - Where a sibling track goes, in folder terms. Genuinely new, genuinely necessary, and genuinely small — see §"The new track".
- The idempotent
Captureprefix. Six lines incore/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):
L = level[srcIdx]— the source's own nesting level.- Insert position
p: ifdepth[srcIdx] >= 1(folder parent),p= the firstj > srcIdxwithlevel[j] == L— i.e. immediately after the whole folder, at the source's own level;countif none. Otherwisep = srcIdx + 1. - Two folder-depth writes, and only two. With
b = p - 1(the track that will precede the new one) andLp = level[p](the level the track currently atpsits at,0at end-of-project): setdepth[b] = L - level[b], and set the new track'sdepth = 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:
- 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
.wavin the project's recording path — precisely what REAPER's own render and record actions do. Not a defect. - 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, wheresnapshots_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. - 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/capturemodules, one new shell TU inshell/capture, one row inmain.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.
src/shell/capture/CLAUDE.md§Invariants — "RunInsertSelectedis 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:RunInsertSelectedplaces a bank sample;render_in_placeplaces a render that never entered the bank. Neither is a capture placing itself.src/shell/actions/CLAUDE.md§Invariants — "arrange_drop_winis the only timeline-placing shell in this directory." Strictly this stays true if Ρ's shell lives inshell/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 asdirectChildIndices.core/capture/capture_name—captureTrackName(sourceName), the idempotent prefix.core/capture/capture_paths—RenderPaths+deriveRenderPaths(absoluteDir, baseName, uniqueTag), withderiveBankPathsre-expressed over it so the stem spelling keeps one owner (bankRelativeForNamealready 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 inshell/capture/rather thanshell/actions/because it composesrenderOfflineandResolveScopeSourceand is genuinely a render path, not a skin over one; the directory's own CLAUDE.md says action bodies belong here and thatshell/actionsonly skins mutation logic owned elsewhere.shell/capture/capture.cpp— the destination branch (~6 lines at the path derivation) andCaptureResult::absolutePath. No behavioural change on the bank path: the enum defaults toBank, and the bank branch must be byte-identical to today.src/app/main.cpp— oneActionTableRow.
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]whetherInsertTrackInProjectat indexpcombined with the twoI_FOLDERDEPTHwrites settles without an intermediateTrackList_AdjustWindows(false)(header 7735; the note at 2721 says some attribute writes need a manual panel update, and theisMinorsemantics 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). GetProjectPathExon 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.