diff --git a/CLAUDE.md b/CLAUDE.md index 0529fc8..6d5a3d6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -200,7 +200,7 @@ Plan-style docs live under `docs/`: ## The load-bearing principle -**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. Placement is a distinct, on-demand action (`insert` module / `InsertMedia`). Any code path that auto-inserts a capture into the timeline violates the purpose of the tool and **must be rejected in review**. +**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. Placement is a distinct, on-demand action (`insert` module / `InsertMedia`). Any code path that auto-inserts a capture into the timeline violates the purpose of the tool and **must be rejected in review**. A render that goes arrange → arrange, never entering the bank and never reading it (`shell/capture/render_in_place`), is a THIRD verb outside this rule rather than a softening of it — the rule binds anything that touches the bank on either side, so a bank sample may still only reach the timeline through an on-demand placement, and a capture may never grow a place step. ## Precision invariants — required before any feature ships diff --git a/src/app/CMakeLists.txt b/src/app/CMakeLists.txt index 75ca5b7..044ab51 100644 --- a/src/app/CMakeLists.txt +++ b/src/app/CMakeLists.txt @@ -16,6 +16,7 @@ add_library(reaper_reasampler MODULE ${REASAMPLER_SRC_DIR}/shell/capture/render_selection.cpp ${REASAMPLER_SRC_DIR}/shell/capture/render_isolation.cpp ${REASAMPLER_SRC_DIR}/shell/capture/render_bounds_gate.cpp + ${REASAMPLER_SRC_DIR}/shell/capture/render_in_place.cpp ${REASAMPLER_SRC_DIR}/shell/capture/realtime_lifecycle.cpp ${REASAMPLER_SRC_DIR}/shell/capture/capture_realtime_shell.cpp ${REASAMPLER_SRC_DIR}/shell/capture/capture_realtime_finalize.cpp diff --git a/src/app/main.cpp b/src/app/main.cpp index c6f8136..57696ae 100644 --- a/src/app/main.cpp +++ b/src/app/main.cpp @@ -31,6 +31,7 @@ #include "shell/capture/capture_batch.h" // batch + recapture action bodies #include "shell/capture/capture_orchestrator.h" // single-capture / realtime / insert action bodies #include "shell/capture/realtime_lifecycle.h" // in-flight realtime state + tick driver +#include "shell/capture/render_in_place.h" // render-in-place action body #include "shell/panel/panel_input.h" // bankPanelRefresh / bankPanelNotifyProjectLoaded #include "shell/panel/panel_window.h" // panel lifecycle (init/toggle/open-query/shutdown) #include "shell/persist/session.h" // ReaSamplerSession @@ -88,6 +89,7 @@ static void RunBatchCaptureRazor(int) { capture::RunBatchCaptureRazor(g_session) static void RunCaptureRealtime(int) { capture::RunCaptureRealtimeTrack(g_session); } static void RunCancelRealtime(int) { capture::RunCancelRealtime(g_session); } static void RunRecaptureFromSource(int) { capture::RunRecaptureFromSource(g_session); } +static void RunRenderTrackInPlace(int) { capture::RunRenderTrackInPlace(g_session); } static void RunResampleBake(int) { capture::RunResampleBake(g_session); } static void RunShowVersion(int) { // On-demand only — no unconditional startup print (routine console chatter pops @@ -134,6 +136,11 @@ static std::vector buildMainActionTable() { &RunCancelRealtime}); rows.push_back({"RECAPTURE_FROM_SOURCE", "re-capture from source", &RunRecaptureFromSource}); + // A RENDER_*, not a CAPTURE_*: the id is permanent and is the most durable + // statement the codebase makes about which pillar a feature belongs to. + rows.push_back({"RENDER_TRACK_IN_PLACE", + "render selected track to a new track (source moves to Design)", + &RunRenderTrackInPlace}); // Invoked by a ReaSampler 9000 instance over the VST3 host bridge (and bindable, so a // stranded request can be landed by hand). The suffix is the wire contract itself — // core/wire/bake_wire owns the spelling both artifacts read. diff --git a/src/core/capture/capture_name.cpp b/src/core/capture/capture_name.cpp index c8aca5c..65209b1 100644 --- a/src/core/capture/capture_name.cpp +++ b/src/core/capture/capture_name.cpp @@ -86,4 +86,19 @@ CaptureName composeCaptureName(const CaptureNameInputs& in) { return out; } +std::string captureTrackName(const std::string& sourceName) { + const std::string prefix(kCaptureTrackPrefix); + // A source with no readable name yields the bare word rather than a trailing + // space; both spellings are fixed points, which is what makes the whole function + // one (a track named exactly "Capture" must not become "Capture Capture"). Read + // from kCaptureTrackPrefixBare rather than chopped off prefix, so the two names + // can't drift out of sync with each other (both expand from the same header token). + const std::string bare = kCaptureTrackPrefixBare; + + if (sourceName.empty()) return bare; + if (sourceName == bare) return sourceName; + if (sourceName.rfind(prefix, 0) == 0) return sourceName; + return prefix + sourceName; +} + } // namespace reasampler::capture diff --git a/src/core/capture/capture_name.h b/src/core/capture/capture_name.h index 803ac60..0225608 100644 --- a/src/core/capture/capture_name.h +++ b/src/core/capture/capture_name.h @@ -59,4 +59,26 @@ std::string formatCaptureStamp(const CaptureStamp& stamp); CaptureName composeCaptureName(const CaptureNameInputs& in); +// The single source of truth for the word itself — kCaptureTrackPrefixBare and +// kCaptureTrackPrefix below both expand from this one token, so editing it can never +// desync captureTrackName's "no readable source name" bare-word fallback from the +// separator-terminated prefix it is derived from. +#define REASAMPLER_CAPTURE_TRACK_WORD "Capture" + +// The bare word behind kCaptureTrackPrefix, needed by captureTrackName's +// no-readable-source-name fallback. +inline constexpr const char* kCaptureTrackPrefixBare = REASAMPLER_CAPTURE_TRACK_WORD; + +// Prefixed onto a source track's name to name the track a render-in-place created. +// A display convention, not a persisted key — unlike a lane prefix or an action-id +// suffix, changing it later strands nothing. +inline constexpr const char* kCaptureTrackPrefix = REASAMPLER_CAPTURE_TRACK_WORD " "; + +// The new track's name for a render of `sourceName`. IDEMPOTENT — a fixed point on +// its own output, so a second render over a result track yields "Capture MONEY" +// again rather than "Capture Capture MONEY". A counter suffix is deliberately not +// offered: REAPER does not uniquify track names either, and what distinguishes two +// renders of one source is their position, not their name. +std::string captureTrackName(const std::string& sourceName); + } // namespace reasampler::capture diff --git a/src/core/capture/capture_paths.cpp b/src/core/capture/capture_paths.cpp index 5d50501..e3ab731 100644 --- a/src/core/capture/capture_paths.cpp +++ b/src/core/capture/capture_paths.cpp @@ -45,16 +45,25 @@ std::string sanitizeStem(const std::string& baseName) { return out; } -BankPaths deriveBankPaths(const std::string& projectDir, - const std::string& baseName, - const std::string& uniqueTag) { - const std::string dir = normalizeSlashes(projectDir); - +RenderPaths deriveRenderPaths(const std::string& absoluteDir, + const std::string& baseName, + const std::string& uniqueTag) { std::string stem = sanitizeStem(baseName); if (!uniqueTag.empty()) { stem += "_" + sanitizeStem(uniqueTag); } - const std::string fileName = stem + ".wav"; + + RenderPaths r; + r.fileStem = stem; // stem only — REAPER appends the extension + r.fileName = stem + ".wav"; + r.absoluteDir = normalizeSlashes(absoluteDir); + return r; +} + +BankPaths deriveBankPaths(const std::string& projectDir, + const std::string& baseName, + const std::string& uniqueTag) { + const std::string dir = normalizeSlashes(projectDir); // Precondition: caller must resolve a non-empty project directory — an // empty one would otherwise fall back to a bare relative path (forbidden). @@ -62,18 +71,19 @@ BankPaths deriveBankPaths(const std::string& projectDir, // ignores it fails at the render/stat step, not silently onto CWD. assert(!dir.empty() && "deriveBankPaths: projectDir must not be empty"); + const RenderPaths r = deriveRenderPaths( + dir.empty() ? std::string{} : dir + "/" + kBankSubfolder, baseName, uniqueTag); + BankPaths p; - p.fileStem = stem; // stem only — REAPER appends extension - p.fileName = fileName; - p.relativePath = std::string(kBankSubfolder) + "/" + fileName; - p.absoluteDir = dir.empty() ? std::string{} - : dir + "/" + kBankSubfolder; + p.fileStem = r.fileStem; + p.fileName = r.fileName; + p.relativePath = bankRelativeForName(r.fileName); + p.absoluteDir = r.absoluteDir; return p; } std::string bankRelativeForName(const std::string& fileName) { if (fileName.empty()) return {}; - // Same expression deriveBankPaths uses, so the two spellings can't drift. return std::string(kBankSubfolder) + "/" + fileName; } diff --git a/src/core/capture/capture_paths.h b/src/core/capture/capture_paths.h index 8ca0157..77049b1 100644 --- a/src/core/capture/capture_paths.h +++ b/src/core/capture/capture_paths.h @@ -36,9 +36,29 @@ std::string normalizeSlashes(const std::string& path); // "capture" if nothing usable remains. Deterministic. std::string sanitizeStem(const std::string& baseName); -// Derives the bank paths for one capture: baseName is the sanitized file-stem -// source, uniqueTag an optional sanitized disambiguator (timestamp/counter) so -// repeated captures don't collide. Produces "[_].wav". +// Where one render writes, with no index spelling at all: the directory REAPER is +// told to render into plus the stem/file name it produces there. `absoluteDir` is +// taken as given (normalized only) rather than derived, because a render that never +// enters the bank has no bank subfolder to append — the render-in-place verb points +// this at the project's own recording path. +struct RenderPaths { + std::string absoluteDir; // RENDER_FILE (forward slash, no trailing slash) + std::string fileName; // .wav + std::string fileStem; // (RENDER_PATTERN — REAPER appends the extension) +}; + +// The file-stem spelling for one render: baseName is the sanitized file-stem source, +// uniqueTag an optional sanitized disambiguator (timestamp/counter) so repeated +// renders don't collide. Produces "[_].wav". THE one owner of that +// spelling — deriveBankPaths is expressed over it, and bankRelativeForName depends +// on the bank's spelling never drifting from it. +RenderPaths deriveRenderPaths(const std::string& absoluteDir, + const std::string& baseName, + const std::string& uniqueTag); + +// Derives the bank paths for one capture: the same stem spelling as +// deriveRenderPaths, in the bank subfolder, plus the project-relative path the +// index stores. BankPaths deriveBankPaths(const std::string& projectDir, const std::string& baseName, const std::string& uniqueTag); diff --git a/src/core/capture/track_topology.cpp b/src/core/capture/track_topology.cpp index 58c9841..5db98ff 100644 --- a/src/core/capture/track_topology.cpp +++ b/src/core/capture/track_topology.cpp @@ -25,4 +25,38 @@ std::vector directChildIndices(const std::vector& folderDepths, return children; } +SiblingPlacement siblingPlacement(const std::vector& folderDepths, int srcIndex) { + const int count = static_cast(folderDepths.size()); + if (count == 0) return SiblingPlacement{}; + + const int src = srcIndex < 0 ? 0 : (srcIndex >= count ? count - 1 : srcIndex); + + // levels[i] is track i's absolute nesting depth; levels[count] is the depth the + // list closes at (0 in a well-formed project). Negative is unrepresentable, so a + // malformed over-closing delta clamps here rather than propagating. + std::vector levels(static_cast(count) + 1, 0); + for (int i = 0; i < count; ++i) { + const int next = levels[static_cast(i)] + + folderDepths[static_cast(i)]; + levels[static_cast(i) + 1] = next < 0 ? 0 : next; + } + + const int L = levels[static_cast(src)]; + + int p = src + 1; + if (folderDepths[static_cast(src)] >= 1) { + p = count; // an unterminated folder swallows the rest of the list + for (int j = src + 1; j <= count; ++j) { + if (levels[static_cast(j)] == L) { p = j; break; } + } + } + + SiblingPlacement out; + out.insertIndex = p; + out.precedingIndex = p - 1; + out.precedingDepth = L - levels[static_cast(p - 1)]; + out.newDepth = levels[static_cast(p)] - L; + return out; +} + } // namespace reasampler::capture diff --git a/src/core/capture/track_topology.h b/src/core/capture/track_topology.h index 2a55fa2..9903e47 100644 --- a/src/core/capture/track_topology.h +++ b/src/core/capture/track_topology.h @@ -1,8 +1,8 @@ #pragma once // track_topology — pure folder arithmetic over a project's track list: which tracks -// are the DIRECT children of a folder parent, derived from the I_FOLDERDEPTH deltas -// alone. NO REAPER types (the shell reads the deltas); unit-tested by -// tests/test_track_topology.cpp. +// are the DIRECT children of a folder parent, and where a new SIBLING of a given +// track goes, both derived from the I_FOLDERDEPTH deltas alone. NO REAPER types +// (the shell reads the deltas); unit-tested by tests/test_track_topology.cpp. #include @@ -21,4 +21,36 @@ namespace reasampler::capture { std::vector directChildIndices(const std::vector& folderDepths, int parentIndex); +// Where a new track goes so it is a SIBLING of `srcIndex` — same nesting level, same +// folder — and the two I_FOLDERDEPTH writes that put it there. +struct SiblingPlacement { + int insertIndex = 0; // the index the new track occupies after insertion + + // The track that will PRECEDE the new one (insertIndex - 1), and its rewritten + // delta. -1 only for a degenerate empty list, where there is nothing to write. + int precedingIndex = -1; + int precedingDepth = 0; + + int newDepth = 0; // the new track's own I_FOLDERDEPTH +}; + +// Both naive answers are audibly wrong, which is why this is arithmetic and not +// `srcIndex + 1`: inserting straight after a folder PARENT makes the new track that +// folder's first child (its audio re-enters the parent's FX and fader), and inserting +// straight after the folder's LAST track steals that track's closing delta and drops +// the new one outside the folder entirely (its audio bypasses the folder bus). +// +// Levels are absolute nesting depths recovered from the deltas (level[0] = 0, +// level[i+1] = level[i] + depth[i]). A folder parent's insert point is the first +// following track back at the source's own level — i.e. after the whole folder; +// everything else inserts directly below the source. On a well-formed delta list +// (one whose deltas sum to zero) the two writes preserve the total delta sum, so no +// track after the insertion changes level — the malformed case below does not carry +// that guarantee; the clamp keeps the result legal, not level-preserving. +// +// A malformed list (deltas not summing to zero, an out-of-range srcIndex) CLAMPS to +// the nearest legal placement rather than asserting: the failure mode of a corrupt +// project must be a track at the wrong nesting level, never a crash. +SiblingPlacement siblingPlacement(const std::vector& folderDepths, int srcIndex); + } // namespace reasampler::capture diff --git a/src/core/view/CLAUDE.md b/src/core/view/CLAUDE.md index b8ecfbd..57b5e93 100644 --- a/src/core/view/CLAUDE.md +++ b/src/core/view/CLAUDE.md @@ -64,8 +64,11 @@ settled 2026-07-23): - **Mechanism: fixed item lanes.** Map mode → lane; toggle drives per-lane play/show so only the active mode's lane is present. Items keep their real position and real track — nothing is moved in time or deleted. -- **Membership: adoption rule for new items; active mode for new tracks.** New - tracks are tagged to the active mode at creation. New items follow an +- **Membership: adoption rule for new items; active mode for new tracks absent an + explicit tag.** New tracks are tagged to the active mode at creation **only when the GUID carries no + membership record** — an explicit tag wins over the detector, because the detector + classifies content the *user* made, not content the tool made and already + classified. New items follow an adoption rule: if the item's track has pre-existing managed-eligible content spanning exactly one mode, the item adopts that mode; the active-mode fallback applies only when the track is empty or already spans multiple diff --git a/src/shell/actions/CLAUDE.md b/src/shell/actions/CLAUDE.md index e5443df..befbaed 100644 --- a/src/shell/actions/CLAUDE.md +++ b/src/shell/actions/CLAUDE.md @@ -16,8 +16,10 @@ is owned by other directories and only skinned here. - **Ingest is an extension act; the instrument is a read-only bank consumer.** Any instrument code path that captures, imports, inserts a timeline item, or writes back into the bank is a bug — the instrument reads and plays only. -- **`arrange_drop_win` is the only timeline-placing shell in this directory**, and - it places because the USER dragged a card onto the arrange. Root `CLAUDE.md`'s +- **`arrange_drop_win` is the only timeline-placing shell IN THIS DIRECTORY** — the + claim scopes here, not to the system: `shell/capture` holds two more + (`RunInsertSelected` and `render_in_place`, the third verb). `arrange_drop_win` + places because the USER dragged a card onto the arrange. Root `CLAUDE.md`'s capture/placement separation forbids a CAPTURE placing an item; a deliberate drop is placement on demand. No other module here may grow an `InsertMedia` call. - **Ingest NEVER inserts a timeline item.** Arrange capture→bank→assign reuses the diff --git a/src/shell/capture/CLAUDE.md b/src/shell/capture/CLAUDE.md index ea057b2..9814735 100644 --- a/src/shell/capture/CLAUDE.md +++ b/src/shell/capture/CLAUDE.md @@ -45,24 +45,29 @@ detail not covered there: `capture_realtime_shell` cannot block REAPER's UI for the duration of a realtime record, so `begin`/`tick`/`abort` are async by construction and the temp-track + send recipe lives in the shell, not the pure core. -- **`RunInsertSelected` is the one deliberate exception to capture-never-places** - (see `capture_orchestrator` below) — every other capture entry point writes only - a file + index entry. +- **This directory hosts TWO placing paths, and neither is a capture placing + itself.** `RunInsertSelected` (see `capture_orchestrator` below) places a *bank + sample*, on demand, which is why it is the deliberate exception to + capture-never-places. `render_in_place` places a render that never entered the + bank — the third verb (arrange → arrange, root `CLAUDE.md` §The load-bearing + principle). Every other entry point here writes only a file + index entry, and no + capture may ever grow a place step. ## Modules -- `capture` — two CONCRETE backends with deliberately different lifecycles (no shared interface — the former `ICaptureBackend` was deleted in Q-W3, T4-26: one deriver, zero polymorphic call sites): `OfflineRenderBackend` (deterministic default, synchronous) and `RealtimeRecordBackend` (async begin/tick/abort). Input: `CaptureRequest`. Output: finished file + populated `Sample` handed to `bank_model`. It also owns the two file-side steps both backends share, in this order: `collapseCapturedFileToMono` (the lossless mono collapse, applied to the landed file) and `stampCaptureSample`, which measures the channel count off that same file so the entry and the audio cannot disagree. And `captureNameFor` — the impure local-clock read the entry points call to build a request's label + stem, kept out of the pure `core/capture/capture_name` composition it feeds. -- `render_bounds_gate` (`shell/capture`) — the exact-bounds verdict on a landed offline render and the refusal's file handling, split off `capture.cpp` on the render-vs-judge seam. Refuses a frame count that is not the window's AND a file whose frames cannot be measured at all (an invalid layout used to skip the gate and land with an unknown channel count). Judges `TailMode::None` only — Auto/Manual add frames by design, and an unmeasurable render still lands under those two (`docs/TODO.md`). A refused render is MOVED to `/reasampler_refused/` rather than deleted, so the frames it did print survive for diagnosis while the short-render root cause is open; the bank never INDEXES it either way — but a failed move leaves the file sitting unindexed in the bank folder itself, not `reasampler_refused/` (the console message says which happened). +- `capture` — two CONCRETE backends with deliberately different lifecycles (no shared interface — the former `ICaptureBackend` was deleted in Q-W3, T4-26: one deriver, zero polymorphic call sites): `OfflineRenderBackend` (deterministic default, synchronous) and `RealtimeRecordBackend` (async begin/tick/abort). Input: `CaptureRequest`. Output: finished file + populated `Sample` — destination-dependent: on `CaptureDestination::Bank` (the default) the `Sample` is handed to `bank_model`; on `ProjectMedia` the file lands outside the bank and the caller (`render_in_place`) discards the returned `Sample`. It also owns the two file-side steps both backends share, in this order: `collapseCapturedFileToMono` (the lossless mono collapse, applied to the landed file) and `stampCaptureSample`, which measures the channel count off that same file so the entry and the audio cannot disagree. And `captureNameFor` — the impure local-clock read the entry points call to build a request's label + stem, kept out of the pure `core/capture/capture_name` composition it feeds. +- `render_bounds_gate` (`shell/capture`) — the exact-bounds verdict on a landed offline render and the refusal's file handling, split off `capture.cpp` on the render-vs-judge seam. Refuses a frame count that is not the window's AND a file whose frames cannot be measured at all (an invalid layout used to skip the gate and land with an unknown channel count). Judges `TailMode::None` only — Auto/Manual add frames by design, and an unmeasurable render still lands under those two (`docs/TODO.md`). Refusal handling is destination-aware (`render_bounds_gate.h`): on `CaptureDestination::Bank`, a refused render is MOVED to `/reasampler_refused/` rather than deleted, so the frames it did print survive for diagnosis while the short-render root cause is open — but a failed move leaves the file sitting unindexed in the bank folder itself, not `reasampler_refused/` (the console message says which happened); the bank never INDEXES it either way. On `CaptureDestination::ProjectMedia` the file is left exactly where the renderer wrote it — no move, no bank folder, no bank language in the message — because that render is the project's own media, not the tool's (`docs/product/render-in-place.md` "Where the file goes"). - `scope_resolve` (`shell/capture`) — scope/source resolution shared by every capture entry point (Q-W3 hoist out of `main.cpp`): razor-else-time range inference, selected-track/selected-item-owning-track collection with canonical GUIDs, and the M10 provenance-assembly inputs (read BEFORE the FX-bypass guard neutralizes the in-scope chain). Also the one place a source track's NAME is read (`trackName`, via `GetTrackName` — chosen over `P_NAME` because it already answers REAPER's `"Track N"` convention for an unnamed track), landed on `ResolvedSource::trackNames` parallel to `sourceTracks` and composed into the capture's label + stem by the pure `core/capture/capture_name`. - `render_selection` (`shell/capture`) — the transient track selection a selected-tracks render (`&128`) requires, as a stack RAII guard: REAPER prints whatever tracks are selected, so `renderOffline` makes the request's own tracks BE the selection for the render's duration and restores the user's set on every exit path. Engaged ONLY for that source mode, which leaves a stated residual: a `&32` selected-items render still prints whatever ITEMS the user has selected. Live captures are unaffected (that selection is the source), but a recipe replay of a `SelectedItems` capture renders against whatever happens to be selected then — the recipe stores tracks and a range, never item GUIDs, so this guard cannot close it. Filed in `docs/TODO.md`. - `render_isolation` (`shell/capture`) — the transient upstream silencing a ranged ITEM render needs, as a stack RAII guard alongside the two above: the selected-tracks source prints everything flowing INTO the track, so each direct folder child's `B_MAINSEND` and each of the track's receives' `B_MUTE` are cut for the render and restored on every exit path. Direct children only — a grandchild reaches the track through the child that owns it. The child-set walk is pure (`core/capture/track_topology`). -- `capture_orchestrator` (`shell/capture`) — single-capture orchestration + the realtime/insert action bodies (Q-W3 hoist, T4-02): `renderOffline` (one offline render under the scope's FX-bypass guard), `captureAndIndexOne` (render + provenance stamp + bank add + tracking-ledger record, unpersisted), `RunCapture`/`RunCaptureItemAssign`, `RunCaptureRealtimeTrack`/`RunCancelRealtime` (the realtime action bodies — the in-flight state lives in `realtime_lifecycle`), and `RunInsertSelected` (the ONE deliberate exception to capture-never-places). +- `capture_orchestrator` (`shell/capture`) — single-capture orchestration + the realtime/insert action bodies (Q-W3 hoist, T4-02): `renderOffline` (one offline render under the scope's FX-bypass guard), `captureAndIndexOne` (render + provenance stamp + bank add + tracking-ledger record, unpersisted), `RunCapture`/`RunCaptureItemAssign`, `RunCaptureRealtimeTrack`/`RunCancelRealtime` (the realtime action bodies — the in-flight state lives in `realtime_lifecycle`), and `RunInsertSelected` (the capture family's deliberate exception to capture-never-places — see the Invariants section above for `render_in_place`, the directory's other placing path, which sits outside the capture family entirely). - `bake_land` (`shell/capture`) — the EXTENSION's half of the resample chain, the SCAN PASS: scans every open project tab for pending `rsbake_*` requests, lands the ones belonging to the project this session has loaded (via `bake_landing`, below), and refuses the rest with `WrongProject` — one undo point for the batch, each answered over its own key inside the invoking instance's synchronous action call. It owns every ext-state read and write in the chain. The per-key verdict itself is NOT this TU's: it is `core/wire`'s pure `classifyBakeScan`, so this shell only enumerates, reads, and applies — counting every verdict into a `wire::BakeScanTally` as it goes, printing `wire::describeBakeKey` for EVERY enumerated key (the only thing that names which key is whose) plus `wire::describeBakeScan` whenever any key went unanswered or any answer's write was not confirmed, in one `ShowConsoleMsg`. It PROVES every write — answer or stale-clear — by reading the key back (`wire::extStateWriteLanded`, whose home is `core/wire/ext_state_read.h`); an answer that did not land is the one no-answer the tally alone cannot show. That proof is three-valued (`wire::BakeWriteProof`): a read-back that overflowed, or a throw AFTER the `SetProjExtState` call, reports Unknown; a throw BEFORE it reports Rejected, because the write is then known not to have been made. Each key is materialized before any answer is written, so no `SetProjExtState` in this action mutates a set the enumerator is still walking. Answers are held UNENCODED until after the pass's single persist, so a landing whose pass never got its persist through is answered as a failure rather than as an `Ok` no reload would honour — `wire::bakeLandingAfterPersist` is the ONE route to a `Banked` landing, and no path here (dedup included) may assign that word itself. The undo block is stack RAII (`UndoBlock`). Both loops are guarded: a throw in the scan still writes the answers already prepared, and a throw in the write-back loop still prints the lines already accumulated — no path through this action can end in a silent console. It RENDERS NOTHING — the instrument already did, through its own engine in its own process, which is what makes the baked audio the sound the user approved and what keeps the voice engine out of the extension's link graph. - `bake_landing` (`shell/capture`) — landing ONE bake request, split off `bake_land` on the one-request / whole-pass seam; touches no REAPER API at all. Non-mutating `prepareLanding` and mutating `commitLanding` sit under separate catches in `attemptLanding` — a throw before anything was written is a clean refusal, a throw after it is reported as possibly partial. Replace-vs-add comes from `tracking::resampleLanding`; a replace keeps the entry's id and slot and never deletes the superseded file. Hash-dedup applies on the add path only, before the disk write, matching `updateSampleInPlace`'s "an in-place refresh is not an insert" — and a dedup hit still rides the pass's persist, because the entry it points at may be one the same pass just added. A refused index withdraws the bytes this call had just written — the self-cleanup carve-out from prune's deletion authority, stated in `prune_fs.cpp`'s header. It never persists: the pass does that once for its whole batch, which is why no landing may report itself as banked. - `capture_batch` (`shell/capture`) — the batch-capture family + re-capture-from-source (Q-W3 hoist, T4-02): `RunBatchCaptureItems` (one sample per selected item), `RunBatchCaptureRazor` (one sample per razor area), `RunRecaptureFromSource` (regenerate a provenanced sample from its recorded source's current state, bank-only). Every unit routes through `capture_orchestrator` so every precision invariant holds; persist is batched to one ext-state write per action. - `realtime_lifecycle` (`shell/capture`) — the in-flight realtime-capture state machine + globals (Q-W3 hoist): the action starts it, `OnTimer` drives it per tick via `DriveRealtimeCapture` (a single-pointer-test idle fast path — load-bearing hot-path guardrail), `CommitRealtimeResult` lands a finished capture in the bank, `AbortRealtimeCaptureForUnload` tears down cleanly on extension unload. - `capture_realtime_shell` (`shell/capture`) — the async realtime-record backend surface (Q-W6 split of the former fat `capture.h`): `RealtimeRecordBackend::begin`/`tick`/`abort`, transport-driven across timer ticks (a realtime record cannot block REAPER's UI for its own duration). Deliberately shares NO interface with the offline backend — the lifecycles genuinely differ (the former `ICaptureBackend` interface was deleted in Q-W3, T4-26). - `capture_realtime_finalize` (`shell/capture`) — the file-side half of the realtime-record shell (Q-W3, T4-08): discovers the file REAPER actually recorded, moves it into the bank, runs the Auto-tail PCM decay-scan trim, and populates the finished `Sample`. +- `render_in_place` (`shell/capture`) — the third verb, arrange → arrange: renders the selected track's output over the resolved range through `renderOffline` with `CaptureDestination::ProjectMedia`, then places the result on a brand-new sibling track at the render window's exact start (unsnapped — this placement IS the null test performed automatically), clones the source's colour and its name through the idempotent `captureTrackName`, and settles both tracks' modes in ONE `UNDO_STATE_ALL` block. Sibling nesting comes from the pure `core/capture/track_topology::siblingPlacement`. The source is tagged Design and the result track + its items are tagged `kArrangeModeId` **explicitly and unconditionally** — never `view.activeModeId()`, and never `untag()`, because the panel's auto-tag detector defers to a membership RECORD. It reads and writes NOTHING in the bank: no `session.bank()`, no `session.book()`, no `recordCreated`, no `bumpBankGeneration`; the `Sample` the backend returns is discarded and its `relativePath` is empty by construction. Traffic is one-way — capture may borrow this render, this placement may never be borrowed back into a capture. - `insert` — placement via `InsertMedia`. **Conform-to-project-tempo is an explicit opt-in flag, never silent stretching.** The mono collapse needs no change here: `insert.cpp` passes only a path to `InsertMedia`, and REAPER derives the item's channel count from the file itself — a 1-channel WAV yields a mono item for free. - `provenance_shell` — FX-chain identity queries via `TrackFX_*`/`TakeFX_*` APIs; feeds the pure `provenance` fingerprint builder. Stamps `Sample.provenance` on capture; ambiguous/mixed cases record nothing conservatively. - `track_guid` — shared `MediaTrack*` → canonical GUID-string formatter; single source of truth for membership keys. diff --git a/src/shell/capture/capture.cpp b/src/shell/capture/capture.cpp index 9c71758..6585ed7 100644 --- a/src/shell/capture/capture.cpp +++ b/src/shell/capture/capture.cpp @@ -39,6 +39,7 @@ #define REAPERAPI_MINIMAL #define REAPERAPI_WANT_EnumProjects +#define REAPERAPI_WANT_GetProjectPathEx #define REAPERAPI_WANT_GetSetProjectInfo #define REAPERAPI_WANT_GetSetProjectInfo_String #define REAPERAPI_WANT_GetSet_LoopTimeRange @@ -414,8 +415,29 @@ CaptureResult OfflineRenderBackend::capture(const CaptureRequest& request) { // Compute the tag ONCE — calling makeUniqueTag() twice would let the file // stem and Sample.id diverge (the counter advances per call). const std::string uniqueTag = makeUniqueTag(""); - const BankPaths paths = - deriveBankPaths(projectDir, request.baseName, uniqueTag); + + // Destination resolves HERE, after the save gate above, so an unsaved project is + // still prompted before any path arithmetic runs. ProjectMedia lands outside the + // bank folder and leaves relativePath empty — the Sample it produces indexes + // nothing (docs/product/render-in-place.md §"Where the file goes"). + RenderPaths paths; + std::string relativePath; + if (request.destination == CaptureDestination::Bank) { + const BankPaths bank = + deriveBankPaths(projectDir, request.baseName, uniqueTag); + paths = RenderPaths{bank.absoluteDir, bank.fileName, bank.fileStem}; + relativePath = bank.relativePath; + } else { + std::vector recDir(4096, '\0'); + GetProjectPathEx(proj, recDir.data(), static_cast(recDir.size())); + paths = deriveRenderPaths(std::string(recDir.data()), request.baseName, + uniqueTag); + if (paths.absoluteDir.empty()) { + result.status = CaptureStatus::NoProject; + result.message = "Could not resolve the project's recording path."; + return result; + } + } ScopedRenderSettings guard(proj); @@ -539,7 +561,7 @@ CaptureResult OfflineRenderBackend::capture(const CaptureRequest& request) { // legitimately produce zero (docs/TODO.md "0-byte render" entry: before this check, // Auto/Manual landed an empty file as CaptureStatus::Ok with channelCount == 0). const BoundsVerdict emptyVerdict = - checkRenderedFileNotEmpty(expectedPath, projectDir); + checkRenderedFileNotEmpty(expectedPath, projectDir, request.destination); if (emptyVerdict.refused) { result.status = CaptureStatus::BoundsMismatch; result.message = emptyVerdict.message; @@ -575,7 +597,7 @@ CaptureResult OfflineRenderBackend::capture(const CaptureRequest& request) { // yield a different value and desync Sample.id from the file name. s.id = "cap-" + uniqueTag + "-" + paths.fileName; s.displayName = request.label(); - s.relativePath = paths.relativePath; // project-relative (invariant) + s.relativePath = relativePath; // project-relative (invariant); empty off the bank s.sourceMode = request.sourceMode; s.sourceRange.startSeconds = request.startSeconds; s.sourceRange.endSeconds = request.endSeconds; @@ -590,12 +612,14 @@ CaptureResult OfflineRenderBackend::capture(const CaptureRequest& request) { // single played note, so no root note is derivable; loop points are set // later by an explicit user action. - result.status = CaptureStatus::Ok; - result.sample = s; + result.status = CaptureStatus::Ok; + result.sample = s; + result.absolutePath = expectedPath; result.message = "Captured [" + std::to_string(request.startSeconds) + "s, " + std::to_string(request.endSeconds) + "s] -> " + - paths.relativePath + monoCollapseSuffix(collapseOutcome); + (relativePath.empty() ? expectedPath : relativePath) + + monoCollapseSuffix(collapseOutcome); return result; } diff --git a/src/shell/capture/capture.h b/src/shell/capture/capture.h index c52b460..dc640f1 100644 --- a/src/shell/capture/capture.h +++ b/src/shell/capture/capture.h @@ -30,6 +30,14 @@ enum class WavBitDepth { Float32, }; +// Where the render lands. TWO VALUES, never a caller-supplied path string: the +// backend resolves each to a directory itself, which is what makes "write into the +// bank folder" inexpressible from the ProjectMedia side and vice versa. +enum class CaptureDestination { + Bank, // /reasampler_bank — every capture path + ProjectMedia, // the project's recording path — the render-in-place verb only +}; + // One capture, independent of source mode. struct CaptureRequest { SourceMode sourceMode = SourceMode::MasterMix; @@ -75,6 +83,9 @@ struct CaptureRequest { // The one home for that fallback rule; both backends populate Sample::displayName // from here rather than each spelling the condition out. std::string label() const { return displayName.empty() ? baseName : displayName; } + + // Default Bank: every existing entry point renders into the bank untouched. + CaptureDestination destination = CaptureDestination::Bank; }; // Every failure is an explicit code, never a thrown exception across the REAPER boundary. @@ -95,6 +106,11 @@ struct CaptureResult { CaptureStatus status = CaptureStatus::RenderFailed; Sample sample; // valid only when status == Ok std::string message; // human-readable detail for the console log + + // The file the render actually landed, absolute — the only handle a caller that + // banks nothing has on its own output (sample.relativePath is empty on the + // ProjectMedia destination). Set on the Ok path only. + std::string absolutePath; }; // Deterministic offline-render backend: master mix / time selection / selected diff --git a/src/shell/capture/render_bounds_gate.cpp b/src/shell/capture/render_bounds_gate.cpp index c0b7311..b67dc4b 100644 --- a/src/shell/capture/render_bounds_gate.cpp +++ b/src/shell/capture/render_bounds_gate.cpp @@ -45,6 +45,25 @@ std::string retainRefusedRender(const std::string& renderedPath, renderedPath + ", indexed by nothing. Delete it when done."; } +// ProjectMedia is the project's own media, never the bank's (docs/product/render-in-place.md +// "Where the file goes") -- a refusal takes no custody of it. No move, no bank folder, no +// mention of a bank the render was never headed for. +std::string leaveRefusedRenderInPlace(const std::string& renderedPath) { + return " The render was left where it was written, at " + renderedPath + + " -- delete it when done."; +} + +// Dispatches the refusal's file-handling sentence by destination, so both verdict +// functions below state one true thing about the file rather than the bank sentence +// on every destination. +std::string refusalOutcome(const std::string& renderedPath, + const std::string& projectDir, + CaptureDestination destination) { + if (destination == CaptureDestination::ProjectMedia) + return leaveRefusedRenderInPlace(renderedPath); + return retainRefusedRender(renderedPath, projectDir); +} + } // namespace std::string refusedRenderFolder(const std::string& projectDir) { @@ -52,7 +71,8 @@ std::string refusedRenderFolder(const std::string& projectDir) { } BoundsVerdict checkRenderedFileNotEmpty(const std::string& renderedPath, - const std::string& projectDir) { + const std::string& projectDir, + CaptureDestination destination) { BoundsVerdict v; std::error_code ec; const std::uintmax_t size = std::filesystem::file_size(renderedPath, ec); @@ -61,7 +81,7 @@ BoundsVerdict checkRenderedFileNotEmpty(const std::string& renderedPath, v.refused = true; v.message = "Render at " + renderedPath + " is 0 bytes -- REAPER produced an empty " "file, so there is nothing to check the requested range against." + - retainRefusedRender(renderedPath, projectDir); + refusalOutcome(renderedPath, projectDir, destination); return v; } @@ -89,7 +109,7 @@ BoundsVerdict checkRenderedBounds(const std::string& renderedPath, "not be read (locked, missing, or a permissions error), its WAV " "header did not parse, or it declared no sample rate -- so the " "frames it holds were never checked against the requested range." + - source + retainRefusedRender(renderedPath, projectDir); + source + refusalOutcome(renderedPath, projectDir, request.destination); return v; } @@ -124,7 +144,7 @@ BoundsVerdict checkRenderedBounds(const std::string& renderedPath, std::to_string(request.endSeconds) + "s) -> frame indices [" + std::to_string(std::llround(request.startSeconds * rate)) + ", " + std::to_string(std::llround(request.endSeconds * rate)) + ")." + - msNote + retainRefusedRender(renderedPath, projectDir); + msNote + refusalOutcome(renderedPath, projectDir, request.destination); return v; } diff --git a/src/shell/capture/render_bounds_gate.h b/src/shell/capture/render_bounds_gate.h index 27a4eb1..55a3433 100644 --- a/src/shell/capture/render_bounds_gate.h +++ b/src/shell/capture/render_bounds_gate.h @@ -10,9 +10,11 @@ namespace reasampler::capture { -// A refused render is MOVED out of the bank, not deleted: while the root cause of a -// short render is open (docs/TODO.md), the frames it did print are the evidence — and -// nothing may index a file the bank never accepted. +// On the Bank destination, a refused render is MOVED out of the bank, not deleted: while +// the root cause of a short render is open (docs/TODO.md), the frames it did print are +// the evidence — and nothing may index a file the bank never accepted. On ProjectMedia, +// the render is the project's own media (docs/product/render-in-place.md "Where the file +// goes"), so a refusal leaves it exactly where it was written — no move, no bank folder. struct BoundsVerdict { bool refused = false; std::string message; // console text; meaningful only when refused @@ -22,7 +24,8 @@ struct BoundsVerdict { // frame count is not the window's (render_window::renderHonoredBounds owns the // tolerance and its limits), or the file cannot be measured at all — an unmeasured // render is not a verified one. TailMode::Auto/Manual add frames by design and are -// never judged here. `projectDir` is where a refused render is parked. +// never judged here. `projectDir` and `request.destination` together decide where a +// refused render is parked. BoundsVerdict checkRenderedBounds(const std::string& renderedPath, const std::string& projectDir, const CaptureRequest& request); @@ -31,11 +34,12 @@ BoundsVerdict checkRenderedBounds(const std::string& renderedPath, // never legitimately produce zero), independent of and ahead of the TailMode::None-only // gate above, which does not run on Auto/Manual at all. BoundsVerdict checkRenderedFileNotEmpty(const std::string& renderedPath, - const std::string& projectDir); + const std::string& projectDir, + CaptureDestination destination); -// Where a refused render is retained -- exposed so a multi-unit caller (batch capture) -// can name the folder once without duplicating the subfolder name `checkRenderedBounds` -// and `checkRenderedFileNotEmpty` already use internally. +// Where a refused Bank-destination render is retained -- exposed so a multi-unit caller +// (batch capture, Bank-only) can name the folder once without duplicating the subfolder +// name `checkRenderedBounds` and `checkRenderedFileNotEmpty` already use internally. std::string refusedRenderFolder(const std::string& projectDir); } // namespace reasampler::capture diff --git a/src/shell/capture/render_in_place.cpp b/src/shell/capture/render_in_place.cpp new file mode 100644 index 0000000..cbe0da0 --- /dev/null +++ b/src/shell/capture/render_in_place.cpp @@ -0,0 +1,231 @@ +// render_in_place.cpp — see render_in_place.h. +// +// Includes reaper_plugin_functions.h WITHOUT REAPERAPI_IMPLEMENT — main.cpp is the +// one TU that defines the API pointers; here they are extern. + +#include "shell/capture/render_in_place.h" + +#include +#include + +#include "core/capture/capture_name.h" // captureTrackName +#include "core/capture/insert_plan.h" // computeInsertMode / InsertOptions +#include "core/capture/render_settings.h" // CaptureScope +#include "core/capture/tail_control.h" // TailSetting +#include "core/capture/track_topology.h" // siblingPlacement +#include "core/view/view_mode_model.h" // kArrangeModeId / kDesignModeId +#include "shell/capture/capture.h" +#include "shell/capture/capture_orchestrator.h" // renderOffline +#include "shell/capture/item_read.h" // itemGuid +#include "shell/capture/scope_resolve.h" // ResolveScopeSource / trackName +#include "shell/capture/track_guid.h" // guidString +#include "shell/panel/panel_input.h" // bankPanelTailSetting +#include "shell/persist/session.h" +#include "shell/view/view.h" // applyMode / mintManagedLanes + +#define REAPERAPI_MINIMAL +#define REAPERAPI_WANT_CountTrackMediaItems +#define REAPERAPI_WANT_CountTracks +#define REAPERAPI_WANT_EnumProjects +#define REAPERAPI_WANT_GetCursorPosition +#define REAPERAPI_WANT_GetMediaTrackInfo_Value +#define REAPERAPI_WANT_GetProjectPathEx +#define REAPERAPI_WANT_GetSetMediaTrackInfo_String +#define REAPERAPI_WANT_GetTrack +#define REAPERAPI_WANT_GetTrackColor +#define REAPERAPI_WANT_GetTrackMediaItem +#define REAPERAPI_WANT_InsertMedia +#define REAPERAPI_WANT_InsertTrackInProject +#define REAPERAPI_WANT_SetEditCurPos +#define REAPERAPI_WANT_SetMediaTrackInfo_Value +#define REAPERAPI_WANT_SetOnlyTrackSelected +#define REAPERAPI_WANT_ShowConsoleMsg +#define REAPERAPI_WANT_TrackList_AdjustWindows +#define REAPERAPI_WANT_Undo_BeginBlock2 +#define REAPERAPI_WANT_Undo_EndBlock2 +#include "reaper_plugin_functions.h" + +namespace reasampler::capture { + +namespace { + +void refuse(const std::string& why) { + ShowConsoleMsg(("ReaSampler render in place: " + why + "\n").c_str()); +} + +// Every track's I_FOLDERDEPTH in track order — the flat delta list the pure +// sibling arithmetic reads. +std::vector folderDepths(ReaProject* proj, int count) { + std::vector depths; + depths.reserve(static_cast(count < 0 ? 0 : count)); + for (int i = 0; i < count; ++i) { + MediaTrack* tr = GetTrack(proj, i); + depths.push_back(tr ? static_cast( + GetMediaTrackInfo_Value(tr, "I_FOLDERDEPTH")) + : 0); + } + return depths; +} + +int indexOfTrack(ReaProject* proj, int count, MediaTrack* wanted) { + for (int i = 0; i < count; ++i) + if (GetTrack(proj, i) == wanted) return i; + return -1; +} + +void setTrackName(MediaTrack* tr, const std::string& name) { + // GetSetMediaTrackInfo_String takes a writable buffer even on the set path. + std::vector buf(name.begin(), name.end()); + buf.push_back('\0'); + GetSetMediaTrackInfo_String(tr, "P_NAME", buf.data(), true); +} + +} // namespace + +void RunRenderTrackInPlace(ReaSamplerSession& session) { + ResolvedSource src; + std::string why; + if (!ResolveScopeSource(CaptureScope::Track, src, why)) { refuse(why); return; } + if (src.sourceTracks.empty() || !src.sourceTracks.front()) { + refuse("no source track resolved"); return; + } + + const TailSetting tail = bankPanelTailSetting(); + const CaptureName name = captureNameFor(src.trackNames, /*ordinal=*/0, "capture"); + + CaptureRequest req; + req.sourceMode = SourceMode::SelectedTracks; + req.startSeconds = src.startSeconds; // exact bounds — no rounding + req.endSeconds = src.endSeconds; + req.wetDry = 1.0; + req.tailMode = tail.mode; + req.tailMs = tail.manualMs; + req.sampleRate = 0; // follow project rate + req.channelCount = 2; + req.bitDepth = WavBitDepth::Float32; + req.baseName = name.stemBase; + req.displayName = name.label; + req.destination = CaptureDestination::ProjectMedia; + // trackGuids left empty: they exist to stamp provenance onto a Sample this verb + // discards. A multi-track selection is refused inside renderOffline, keyed on the + // render source, so there is no check to add here. + + const CaptureResult res = renderOffline(CaptureScope::Track, src.sourceTracks, req); + if (res.status != CaptureStatus::Ok) { refuse(res.message); return; } + + MediaTrack* source = src.sourceTracks.front(); + ReaProject* proj = EnumProjects(-1, nullptr, 0); + + const int trackCount = CountTracks(proj); + const int srcIndex = indexOfTrack(proj, trackCount, source); + if (srcIndex < 0) { + refuse("the source track is no longer in the project; the render landed at " + + res.absolutePath + " but was not placed."); + return; + } + + const SiblingPlacement place = + siblingPlacement(folderDepths(proj, trackCount), srcIndex); + + // Read ONCE, and only to reapply the mode / decide whether the result landed + // visible — never to choose a tag. Both tags below are absolute. + const std::string activeMode = session.view().activeModeId(); + + Undo_BeginBlock2(nullptr); + + // flags = 0, never 1: flags&1 adds default envelopes/FX, and a default chain + // would process a render that already carries the source's FX a second time. + InsertTrackInProject(proj, place.insertIndex, /*flags=*/0); + MediaTrack* fresh = GetTrack(proj, place.insertIndex); + if (!fresh) { + // InsertTrackInProject already mutated the project by this point, so the + // "no ext-state write -> discard" idiom does not apply here — a discard would + // leave the orphaned track un-undoable. + Undo_EndBlock2(nullptr, "ReaSampler: render in place (failed to create result track)", + -1); + refuse("could not create the result track; the render landed at " + + res.absolutePath + " but was not placed."); + return; + } + + // Both writes or none — one alone lands the new track at the wrong nesting level, + // which is audible in both directions (see siblingPlacement). + if (place.precedingIndex >= 0) { + if (MediaTrack* preceding = GetTrack(proj, place.precedingIndex)) + SetMediaTrackInfo_Value(preceding, "I_FOLDERDEPTH", + static_cast(place.precedingDepth)); + } + SetMediaTrackInfo_Value(fresh, "I_FOLDERDEPTH", + static_cast(place.newDepth)); + + // GetTrackColor returns the colour already OR'd with 0x1000000 and 0 for "no + // colour set", which I_CUSTOMCOLOR reads as unused — so one line clones a colour + // and the absence of one, with no branch. + SetMediaTrackInfo_Value(fresh, "I_CUSTOMCOLOR", + static_cast(GetTrackColor(source))); + const std::string freshName = captureTrackName(trackName(source)); + setTrackName(fresh, freshName); + + // After every attribute write, per the SDK header's manual-panel-update caveat. + TrackList_AdjustWindows(false); + + // Unsnapped and unrounded, deliberately: this placement IS the null test performed + // automatically, so snapping it to the grid would move the audio off the position + // it was rendered from. InsertOptions{} defaults give native length and no conform. + const double cursorPos = GetCursorPosition(); + SetOnlyTrackSelected(fresh); + SetEditCurPos(src.startSeconds, false, false); + // InsertMedia's int return isn't SDK-documented; treated conservatively as + // 0 = failure, matching performArrangeDrop — an empty result track would + // otherwise be a silent no-op, which is exactly what this verb must not produce. + const bool placed = + InsertMedia(res.absolutePath.c_str(), computeInsertMode(InsertOptions{})) != 0; + SetEditCurPos(cursorPos, false, false); + // The new track is left selected, alone — in the headline case the source is being + // parked out of sight in the same gesture, so restoring the selection would leave + // the user selecting an invisible track. + + // Absolute, not mode-following: the source parks on the bench, the result is an + // Arrange member whatever mode was active. Explicit records rather than untag(), + // because the record is what the panel's auto-tag detector defers to. + MembershipIndex& membership = session.view().membership(); + membership.tag(guidString(source), kDesignModeId); + membership.tag(guidString(fresh), kArrangeModeId); + + // The track is brand new, so its items are exactly the ones just placed. An + // untagged item would be handed to the detector, which tags to the active mode. + const int itemCount = CountTrackMediaItems(fresh); + for (int i = 0; i < itemCount; ++i) { + if (MediaItem* it = GetTrackMediaItem(fresh, i)) { + const std::string ig = itemGuid(it); + if (!ig.empty()) membership.tag(ig, kArrangeModeId); + } + } + + mintManagedLanes(session.view(), nullptr); + applyMode(session.view(), activeMode, nullptr); // a reapply, never a switch + + Undo_EndBlock2(nullptr, "ReaSampler: render selected track to a new track", -1); + + // Persist outside the block. The offline render's own save gate already forced a + // saved project, so the Save-As-guarded persist the Design View actions need + // cannot have anything to prompt for here. + session.saveToActiveProject(); + + if (!placed) { + refuse("the render landed at " + res.absolutePath + + " but REAPER refused to place it — the new track is empty."); + return; + } + + // Silent on success — the new track is the feedback. Except when it is not: fired + // outside Arrange the result track is parked, so a silent success would be + // indistinguishable from a no-op. + if (activeMode != kArrangeModeId) { + ShowConsoleMsg(("ReaSampler render in place: created \"" + freshName + + "\" in Arrange (switch to Arrange to see it).\n") + .c_str()); + } +} + +} // namespace reasampler::capture diff --git a/src/shell/capture/render_in_place.h b/src/shell/capture/render_in_place.h new file mode 100644 index 0000000..249e0f5 --- /dev/null +++ b/src/shell/capture/render_in_place.h @@ -0,0 +1,18 @@ +#pragma once +// render_in_place — the third verb: render the selected track's output over the +// current range to the project's recording path, place it on a new sibling track at +// the exact position it was rendered from, and move the source to Design. The bank +// is never read, written, or notified (docs/product/render-in-place.md). + +namespace reasampler { +class ReaSamplerSession; +} + +namespace reasampler::capture { + +// Resolves, renders, creates + dresses the sibling track, places the file, and +// settles both tracks' modes in one undo block. Silent on success (the new track is +// the feedback) except when the result lands invisible; ShowConsoleMsg on refusal. +void RunRenderTrackInPlace(ReaSamplerSession& session); + +} // namespace reasampler::capture diff --git a/src/shell/panel/panel_input.cpp b/src/shell/panel/panel_input.cpp index 8fbd912..7eb5b79 100644 --- a/src/shell/panel/panel_input.cpp +++ b/src/shell/panel/panel_input.cpp @@ -5,6 +5,7 @@ // Compiled into the reaper_reasampler MODULE, without REAPERAPI_IMPLEMENT (main.cpp // owns the API pointers). DAW-verified, not unit-tested. +#include #include #include #include @@ -163,11 +164,19 @@ bool detectNewContent() { std::map> trackItemGuids; enumerateLiveGuids(proj, live, itemOnManualLane, trackItemGuids); - const std::vector added = g_panel.contentBaseline.observe(live); + std::vector added = g_panel.contentBaseline.observe(live); if (added.empty()) return false; // first poll after open, or nothing new this tick ViewModeModel& model = g_panel.session->view(); + // An explicit tag wins: this detector classifies content the USER made, not + // content the tool made and already classified. + added.erase(std::remove_if(added.begin(), added.end(), + [&](const std::string& g) { + return model.membership().query(g) != nullptr; + }), + added.end()); + // Which of `added` are items (the manual-lane map keys every item; track GUIDs never // appear there). Used below to exclude sibling new items from a track's PRE-EXISTING // mode set — a drop plus its own new siblings must not count each other as prior. diff --git a/tests/test_capture_name.cpp b/tests/test_capture_name.cpp index 722c45b..da43228 100644 --- a/tests/test_capture_name.cpp +++ b/tests/test_capture_name.cpp @@ -241,6 +241,41 @@ static void testEveryAwkwardStemStaysFilesystemLegal() { } } +// --- captureTrackName ------------------------------------------------------- + +static void testCaptureTrackNamePrefixesAPlainSourceName() { + CHECK(captureTrackName("MONEY") == "Capture MONEY"); + CHECK(captureTrackName("bass di") == "Capture bass di"); +} + +static void testCaptureTrackNameIsIdempotent() { + // The whole point: a second render over a result track must not stack the prefix. + CHECK(captureTrackName("Capture MONEY") == "Capture MONEY"); + CHECK(captureTrackName(captureTrackName("MONEY")) == "Capture MONEY"); + // A fixed point on its own output for EVERY input, degenerate ones included. + for (const char* src : {"MONEY", "", "Capture", "Capture ", "Captured drums"}) { + const std::string once = captureTrackName(src); + CHECK(captureTrackName(once) == once); + } +} + +static void testCaptureTrackNameEmptySourceHasNoTrailingSpace() { + // Unreachable from trackName (GetTrackName always answers "Track N"), so this is + // the defensive case — a bare word rather than a name ending in a space. + CHECK(captureTrackName("") == "Capture"); +} + +static void testCaptureTrackNameUnnamedSourceReadsAsCaptureTrackN() { + // trackName's GetTrackName fallback rides in as an ordinary name. + CHECK(captureTrackName("Track 7") == "Capture Track 7"); +} + +static void testCaptureTrackNameDoesNotMatchAMerePrefixOfTheWord() { + // "Captured" begins with "Capture" but not with "Capture " — it is a different + // name and must be prefixed like any other. + CHECK(captureTrackName("Captured drums") == "Capture Captured drums"); +} + int main() { testStampIsZeroPaddedMonthDayHourMinute(); testUnsetStampProducesNoDiscriminator(); @@ -268,6 +303,12 @@ int main() { testOrdinalAndMultiSourceCompose(); testEveryAwkwardStemStaysFilesystemLegal(); + testCaptureTrackNamePrefixesAPlainSourceName(); + testCaptureTrackNameIsIdempotent(); + testCaptureTrackNameEmptySourceHasNoTrailingSpace(); + testCaptureTrackNameUnnamedSourceReadsAsCaptureTrackN(); + testCaptureTrackNameDoesNotMatchAMerePrefixOfTheWord(); + if (g_fail == 0) std::printf("capture_name: all tests passed\n"); else std::printf("capture_name: %d CHECK(s) FAILED\n", g_fail); return g_fail ? 1 : 0; diff --git a/tests/test_capture_paths.cpp b/tests/test_capture_paths.cpp index 3b1b95c..25e9640 100644 --- a/tests/test_capture_paths.cpp +++ b/tests/test_capture_paths.cpp @@ -362,6 +362,39 @@ static void testBankRelativeForNameMatchesDerivePathSpelling() { CHECK(bankRelativeForName(p.fileName) == p.relativePath); } +// --- deriveRenderPaths ------------------------------------------------------ + +static void testRenderPathsSpellTheStemExactlyAsTheBankPathDoes() { + // The one owner claim, made checkable: for the same baseName + uniqueTag, the + // bank path's stem and file name must BE the render path's. If these ever + // diverge, bankRelativeForName's exact-string match against an enumerated + // folder entry starts misfiring and prune misreads referenced files as orphans. + const BankPaths bank = deriveBankPaths("/proj", "kick drum!", "001"); + const RenderPaths render = deriveRenderPaths("/proj/reasampler_bank", + "kick drum!", "001"); + CHECK(render.fileStem == bank.fileStem); + CHECK(render.fileName == bank.fileName); + CHECK(render.absoluteDir == bank.absoluteDir); +} + +static void testRenderPathsTakeTheirDirectoryVerbatim() { + // No bank subfolder is appended — a render outside the bank has none, which is + // what makes "write into the bank folder" inexpressible through this call. + const RenderPaths r = deriveRenderPaths("/proj/media/", "take", ""); + CHECK(r.absoluteDir == normalizeSlashes("/proj/media")); + CHECK(r.fileName == "take.wav"); + CHECK(r.fileStem == "take"); + // Backslashes normalize and a trailing slash is stripped, same as everywhere. + CHECK(deriveRenderPaths("C:\\proj\\media\\", "take", "").absoluteDir == + normalizeSlashes("C:/proj/media")); +} + +static void testRenderPathsEmptyDirectoryStaysEmpty() { + // No CWD fallback: an unresolvable directory must fail at the caller's own + // guard, never silently render next to whatever the process happened to be in. + CHECK(deriveRenderPaths("", "take", "001").absoluteDir.empty()); +} + static void testBankRelativeForNameConventionAndEdge() { // The convention verbatim: "reasampler_bank/" (the one place the spelling lives). CHECK(bankRelativeForName("a.wav") == "reasampler_bank/a.wav"); @@ -396,6 +429,9 @@ int main() { testTransitionFirstSaveOfUnsavedRelocatesButPlanNoOps(); testTransitionInPlaceSaveIsNoOp(); testBankRelativeForNameMatchesDerivePathSpelling(); + testRenderPathsSpellTheStemExactlyAsTheBankPathDoes(); + testRenderPathsTakeTheirDirectoryVerbatim(); + testRenderPathsEmptyDirectoryStaysEmpty(); testBankRelativeForNameConventionAndEdge(); if (g_fail == 0) std::printf("capture_paths: all tests passed\n"); diff --git a/tests/test_track_topology.cpp b/tests/test_track_topology.cpp index 3963927..8127056 100644 --- a/tests/test_track_topology.cpp +++ b/tests/test_track_topology.cpp @@ -5,6 +5,7 @@ #include "../src/core/capture/track_topology.h" +#include #include #include @@ -79,6 +80,143 @@ static void testSiblingFolderAfterParentClosesIsNotIncluded() { CHECK(sameIndices(directChildIndices(depths, 0), {1, 2})); } +// --- siblingPlacement ------------------------------------------------------- +// +// Every case asserts the property that actually matters, not just the numbers: the +// new track sits at the SOURCE's own nesting level, and the delta total is +// unchanged so no track after the insertion moves. `levelsAfter` rebuilds the +// post-insertion list and reads the levels straight off it. + +static std::vector depthsAfter(const std::vector& depths, + const SiblingPlacement& p) { + std::vector out = depths; + if (p.precedingIndex >= 0) out[static_cast(p.precedingIndex)] = p.precedingDepth; + out.insert(out.begin() + p.insertIndex, p.newDepth); + return out; +} + +static int sumOf(const std::vector& v) { + int s = 0; + for (int d : v) s += d; + return s; +} + +// Absolute nesting level of track `idx` in a delta list. +static int levelAt(const std::vector& depths, int idx) { + int level = 0; + for (int i = 0; i < idx; ++i) level += depths[static_cast(i)]; + return level; +} + +// The whole contract in one call: the new track is a sibling (same level as the +// source) and nothing downstream shifted (delta total preserved). +static void checkIsSibling(const std::vector& before, int srcIdx) { + const SiblingPlacement p = siblingPlacement(before, srcIdx); + const std::vector after = depthsAfter(before, p); + CHECK(sumOf(after) == sumOf(before)); + CHECK(levelAt(after, p.insertIndex) == levelAt(before, srcIdx)); +} + +static void testSiblingOfANormalTrackGoesDirectlyBelowIt() { + // Three normal tracks at top level; the source is the middle one. + const std::vector depths{0, 0, 0}; + const SiblingPlacement p = siblingPlacement(depths, 1); + CHECK(p.insertIndex == 2); + CHECK(p.precedingIndex == 1); + CHECK(p.precedingDepth == 0); // unchanged + CHECK(p.newDepth == 0); + checkIsSibling(depths, 1); +} + +static void testSiblingOfAMidFolderTrackStaysInsideTheFolder() { + // 0: parent, 1: child (the source), 2: last child closing the folder. + const std::vector depths{1, 0, -1}; + const SiblingPlacement p = siblingPlacement(depths, 1); + CHECK(p.insertIndex == 2); + CHECK(p.precedingDepth == 0); + CHECK(p.newDepth == 0); // still inside; track 2 still closes the folder + checkIsSibling(depths, 1); +} + +static void testSiblingOfTheLastTrackInAFolderInheritsTheClosingDelta() { + // The source carries the folder's close, so a naive insert-after would drop the + // new track OUTSIDE the folder and bypass the folder bus entirely. + const std::vector depths{1, -1, 0}; + const SiblingPlacement p = siblingPlacement(depths, 1); + CHECK(p.insertIndex == 2); + CHECK(p.precedingDepth == 0); // the source no longer closes the folder + CHECK(p.newDepth == -1); // the new track does + checkIsSibling(depths, 1); +} + +static void testSiblingOfTheLastTrackInTwoFoldersMovesTheWholeClose() { + // 0: outer parent, 1: inner parent, 2: last in BOTH folders (the source). + const std::vector depths{1, 1, -2}; + const SiblingPlacement p = siblingPlacement(depths, 2); + CHECK(p.insertIndex == 3); + CHECK(p.precedingDepth == 0); + CHECK(p.newDepth == -2); // the -2 travels intact + checkIsSibling(depths, 2); +} + +static void testSiblingOfAFolderParentLandsAfterTheWholeFolder() { + // Inserting straight after a folder parent would make the new track its FIRST + // CHILD, re-summing the render through the parent's FX and fader. + const std::vector depths{1, 0, -1, 0}; + const SiblingPlacement p = siblingPlacement(depths, 0); + CHECK(p.insertIndex == 3); // past the whole folder, not at index 1 + CHECK(p.precedingIndex == 2); + CHECK(p.precedingDepth == -1); // unchanged — track 2 still closes the folder + CHECK(p.newDepth == 0); + checkIsSibling(depths, 0); +} + +static void testSiblingOfTheLastTrackInTheProjectAppends() { + const std::vector depths{0, 0}; + const SiblingPlacement p = siblingPlacement(depths, 1); + CHECK(p.insertIndex == 2); // == count: appended + CHECK(p.precedingDepth == 0); + CHECK(p.newDepth == 0); + checkIsSibling(depths, 1); +} + +static void testSiblingOfTheLastTrackInTheProjectInsideAFolder() { + // The project's last track also closes a folder — the close must still travel. + const std::vector depths{1, -1}; + const SiblingPlacement p = siblingPlacement(depths, 1); + CHECK(p.insertIndex == 2); + CHECK(p.precedingDepth == 0); + CHECK(p.newDepth == -1); + checkIsSibling(depths, 1); +} + +static void testMalformedDeltaListClampsRatherThanAsserting() { + // Deltas summing to -3: more closes than opens, which no well-formed project + // produces. The result must still be a legal in-range placement. + const std::vector depths{0, -2, -1}; + const SiblingPlacement p = siblingPlacement(depths, 1); + CHECK(p.insertIndex >= 0 && p.insertIndex <= static_cast(depths.size())); + CHECK(p.precedingIndex == p.insertIndex - 1); + // Clamped at zero rather than tracking a negative nesting level. + CHECK(levelAt(depthsAfter(depths, p), p.insertIndex) >= 0); + + // An unterminated folder (deltas summing to +1) is the other direction. + const std::vector open{1, 0}; + const SiblingPlacement q = siblingPlacement(open, 1); + CHECK(q.insertIndex == 2); + CHECK(q.newDepth <= 0); // never invents a second folder open +} + +static void testOutOfRangeSourceIndexClamps() { + const std::vector depths{0, 0}; + // Past the end clamps to the last track; negative clamps to the first. + CHECK(siblingPlacement(depths, 99).insertIndex == 2); + CHECK(siblingPlacement(depths, -5).insertIndex == 1); + // An empty project has nothing to precede the new track. + CHECK(siblingPlacement({}, 0).insertIndex == 0); + CHECK(siblingPlacement({}, 0).precedingIndex == -1); +} + int main() { testFlatProjectHasNoChildren(); testFolderParentReturnsItsDirectChildren(); @@ -88,6 +226,16 @@ int main() { testUnterminatedFolderSwallowsTheRest(); testSiblingFolderAfterParentClosesIsNotIncluded(); + testSiblingOfANormalTrackGoesDirectlyBelowIt(); + testSiblingOfAMidFolderTrackStaysInsideTheFolder(); + testSiblingOfTheLastTrackInAFolderInheritsTheClosingDelta(); + testSiblingOfTheLastTrackInTwoFoldersMovesTheWholeClose(); + testSiblingOfAFolderParentLandsAfterTheWholeFolder(); + testSiblingOfTheLastTrackInTheProjectAppends(); + testSiblingOfTheLastTrackInTheProjectInsideAFolder(); + testMalformedDeltaListClampsRatherThanAsserting(); + testOutOfRangeSourceIndexClamps(); + if (g_fail == 0) std::printf("track_topology: all tests passed\n"); return g_fail == 0 ? 0 : 1; } diff --git a/tests/test_view_mode_model.cpp b/tests/test_view_mode_model.cpp index f718198..b69cdd3 100644 --- a/tests/test_view_mode_model.cpp +++ b/tests/test_view_mode_model.cpp @@ -1739,6 +1739,48 @@ static void testLaneMintingEmptyFolderNotSplit() { // -- D2.6 JSON round-trip with lane index + membership ----------------------- +// An EXPLICIT Arrange record is new: the shipped "tag selected tracks -> Arrange" +// action untags instead, so until now Arrange was only ever represented by absence. +// The render-in-place verb writes one, because the record — not the behaviour — is +// what the panel's auto-tag detector defers to. It must be indistinguishable from +// absence everywhere else. +static void testExplicitArrangeRecordRoundTripsAndBehavesLikeAbsence() { + ViewModeModel vm; + vm.membership().tag("{TAGGED-ARRANGE}", kArrangeModeId); + // "{UNTAGGED}" is deliberately never tagged — the comparison partner. + + const std::string json = vm.serialize(); + const auto back = ViewModeModel::deserialize(json); + CHECK(back.has_value()); + CHECK(back && *back == vm); + if (back) CHECK(back->serialize() == json); + + // The record survives as a record, not collapsed away on the round-trip. + if (back) { + const Membership* m = back->membership().query("{TAGGED-ARRANGE}"); + CHECK(m != nullptr); + CHECK(m && m->modeIds == std::set{kArrangeModeId}); + CHECK(back->membership().query("{UNTAGGED}") == nullptr); + } + + // Membership answers identically for the record and for its absence, in BOTH + // modes — that equivalence is what makes writing the record free of behaviour. + const auto checkEquivalent = [](const ViewModeModel& m) { + CHECK(m.leafBelongsToMode("{TAGGED-ARRANGE}", kArrangeModeId) == + m.leafBelongsToMode("{UNTAGGED}", kArrangeModeId)); + CHECK(m.leafBelongsToMode("{TAGGED-ARRANGE}", kArrangeModeId)); + CHECK(m.leafBelongsToMode("{TAGGED-ARRANGE}", kDesignModeId) == + m.leafBelongsToMode("{UNTAGGED}", kDesignModeId)); + CHECK(!m.leafBelongsToMode("{TAGGED-ARRANGE}", kDesignModeId)); + }; + checkEquivalent(vm); + if (back) checkEquivalent(*back); // and after a save/reload round-trip + + // untag() still returns it to absence, so the existing way out still works. + CHECK(vm.membership().untag("{TAGGED-ARRANGE}")); + CHECK(vm.membership().query("{TAGGED-ARRANGE}") == nullptr); +} + static void testLaneJsonRoundTrip() { ViewModeModel vm; CHECK(vm.modes().add(Mode{"mixdown", "Mixdown", 2})); @@ -1922,6 +1964,7 @@ int main() { testLaneMintingShowBothNotForceSplit(); testLaneMintingSingleModeLeafVisibleOnceNoSplit(); testLaneMintingEmptyFolderNotSplit(); + testExplicitArrangeRecordRoundTripsAndBehavesLikeAbsence(); testLaneJsonRoundTrip(); testLaneMalformedJson();