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..9126cc3 100644 --- a/src/core/capture/capture_name.cpp +++ b/src/core/capture/capture_name.cpp @@ -86,4 +86,17 @@ 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"). + const std::string bare = prefix.substr(0, prefix.size() - 1); + + 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..1e7133b 100644 --- a/src/core/capture/capture_name.h +++ b/src/core/capture/capture_name.h @@ -59,4 +59,16 @@ std::string formatCaptureStamp(const CaptureStamp& stamp); CaptureName composeCaptureName(const CaptureNameInputs& in); +// 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 = "Capture "; + +// 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..96cc567 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,34 @@ 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. The two writes preserve the +// total delta sum, so no track after the insertion changes level. +// +// 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..e3a64b1 100644 --- a/src/core/view/CLAUDE.md +++ b/src/core/view/CLAUDE.md @@ -65,7 +65,10 @@ settled 2026-07-23): 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 + 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..94fb9d2 100644 --- a/src/shell/capture/CLAUDE.md +++ b/src/shell/capture/CLAUDE.md @@ -45,9 +45,13 @@ 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 @@ -63,6 +67,7 @@ detail not covered there: - `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..4fdf6e4 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); @@ -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_in_place.cpp b/src/shell/capture/render_in_place.cpp new file mode 100644 index 0000000..dd53752 --- /dev/null +++ b/src/shell/capture/render_in_place.cpp @@ -0,0 +1,223 @@ +// 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. +// +// Traffic is one-way: this borrows capture's render, and capture may never borrow +// this placement back. + +#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"); 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) { + Undo_EndBlock2(nullptr, "", 0); + refuse("could not create the result track"); + 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)); + TrackList_AdjustWindows(false); + + // 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); + + // 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();