Render in place: a track's output to a new sibling, source to the bench
This commit is contained in:
@@ -200,7 +200,7 @@ Plan-style docs live under `docs/`:
|
|||||||
|
|
||||||
## The load-bearing principle
|
## 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
|
## Precision invariants — required before any feature ships
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ add_library(reaper_reasampler MODULE
|
|||||||
${REASAMPLER_SRC_DIR}/shell/capture/render_selection.cpp
|
${REASAMPLER_SRC_DIR}/shell/capture/render_selection.cpp
|
||||||
${REASAMPLER_SRC_DIR}/shell/capture/render_isolation.cpp
|
${REASAMPLER_SRC_DIR}/shell/capture/render_isolation.cpp
|
||||||
${REASAMPLER_SRC_DIR}/shell/capture/render_bounds_gate.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/realtime_lifecycle.cpp
|
||||||
${REASAMPLER_SRC_DIR}/shell/capture/capture_realtime_shell.cpp
|
${REASAMPLER_SRC_DIR}/shell/capture/capture_realtime_shell.cpp
|
||||||
${REASAMPLER_SRC_DIR}/shell/capture/capture_realtime_finalize.cpp
|
${REASAMPLER_SRC_DIR}/shell/capture/capture_realtime_finalize.cpp
|
||||||
|
|||||||
@@ -31,6 +31,7 @@
|
|||||||
#include "shell/capture/capture_batch.h" // batch + recapture action bodies
|
#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/capture_orchestrator.h" // single-capture / realtime / insert action bodies
|
||||||
#include "shell/capture/realtime_lifecycle.h" // in-flight realtime state + tick driver
|
#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_input.h" // bankPanelRefresh / bankPanelNotifyProjectLoaded
|
||||||
#include "shell/panel/panel_window.h" // panel lifecycle (init/toggle/open-query/shutdown)
|
#include "shell/panel/panel_window.h" // panel lifecycle (init/toggle/open-query/shutdown)
|
||||||
#include "shell/persist/session.h" // ReaSamplerSession
|
#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 RunCaptureRealtime(int) { capture::RunCaptureRealtimeTrack(g_session); }
|
||||||
static void RunCancelRealtime(int) { capture::RunCancelRealtime(g_session); }
|
static void RunCancelRealtime(int) { capture::RunCancelRealtime(g_session); }
|
||||||
static void RunRecaptureFromSource(int) { capture::RunRecaptureFromSource(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 RunResampleBake(int) { capture::RunResampleBake(g_session); }
|
||||||
static void RunShowVersion(int) {
|
static void RunShowVersion(int) {
|
||||||
// On-demand only — no unconditional startup print (routine console chatter pops
|
// On-demand only — no unconditional startup print (routine console chatter pops
|
||||||
@@ -134,6 +136,11 @@ static std::vector<reasampler::ActionTableRow> buildMainActionTable() {
|
|||||||
&RunCancelRealtime});
|
&RunCancelRealtime});
|
||||||
rows.push_back({"RECAPTURE_FROM_SOURCE", "re-capture from source",
|
rows.push_back({"RECAPTURE_FROM_SOURCE", "re-capture from source",
|
||||||
&RunRecaptureFromSource});
|
&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
|
// 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 —
|
// stranded request can be landed by hand). The suffix is the wire contract itself —
|
||||||
// core/wire/bake_wire owns the spelling both artifacts read.
|
// core/wire/bake_wire owns the spelling both artifacts read.
|
||||||
|
|||||||
@@ -86,4 +86,17 @@ CaptureName composeCaptureName(const CaptureNameInputs& in) {
|
|||||||
return out;
|
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
|
} // namespace reasampler::capture
|
||||||
|
|||||||
@@ -59,4 +59,16 @@ std::string formatCaptureStamp(const CaptureStamp& stamp);
|
|||||||
|
|
||||||
CaptureName composeCaptureName(const CaptureNameInputs& in);
|
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
|
} // namespace reasampler::capture
|
||||||
|
|||||||
@@ -45,16 +45,25 @@ std::string sanitizeStem(const std::string& baseName) {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
BankPaths deriveBankPaths(const std::string& projectDir,
|
RenderPaths deriveRenderPaths(const std::string& absoluteDir,
|
||||||
const std::string& baseName,
|
const std::string& baseName,
|
||||||
const std::string& uniqueTag) {
|
const std::string& uniqueTag) {
|
||||||
const std::string dir = normalizeSlashes(projectDir);
|
|
||||||
|
|
||||||
std::string stem = sanitizeStem(baseName);
|
std::string stem = sanitizeStem(baseName);
|
||||||
if (!uniqueTag.empty()) {
|
if (!uniqueTag.empty()) {
|
||||||
stem += "_" + sanitizeStem(uniqueTag);
|
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
|
// Precondition: caller must resolve a non-empty project directory — an
|
||||||
// empty one would otherwise fall back to a bare relative path (forbidden).
|
// 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.
|
// ignores it fails at the render/stat step, not silently onto CWD.
|
||||||
assert(!dir.empty() && "deriveBankPaths: projectDir must not be empty");
|
assert(!dir.empty() && "deriveBankPaths: projectDir must not be empty");
|
||||||
|
|
||||||
|
const RenderPaths r = deriveRenderPaths(
|
||||||
|
dir.empty() ? std::string{} : dir + "/" + kBankSubfolder, baseName, uniqueTag);
|
||||||
|
|
||||||
BankPaths p;
|
BankPaths p;
|
||||||
p.fileStem = stem; // stem only — REAPER appends extension
|
p.fileStem = r.fileStem;
|
||||||
p.fileName = fileName;
|
p.fileName = r.fileName;
|
||||||
p.relativePath = std::string(kBankSubfolder) + "/" + fileName;
|
p.relativePath = bankRelativeForName(r.fileName);
|
||||||
p.absoluteDir = dir.empty() ? std::string{}
|
p.absoluteDir = r.absoluteDir;
|
||||||
: dir + "/" + kBankSubfolder;
|
|
||||||
return p;
|
return p;
|
||||||
}
|
}
|
||||||
|
|
||||||
std::string bankRelativeForName(const std::string& fileName) {
|
std::string bankRelativeForName(const std::string& fileName) {
|
||||||
if (fileName.empty()) return {};
|
if (fileName.empty()) return {};
|
||||||
// Same expression deriveBankPaths uses, so the two spellings can't drift.
|
|
||||||
return std::string(kBankSubfolder) + "/" + fileName;
|
return std::string(kBankSubfolder) + "/" + fileName;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,9 +36,29 @@ std::string normalizeSlashes(const std::string& path);
|
|||||||
// "capture" if nothing usable remains. Deterministic.
|
// "capture" if nothing usable remains. Deterministic.
|
||||||
std::string sanitizeStem(const std::string& baseName);
|
std::string sanitizeStem(const std::string& baseName);
|
||||||
|
|
||||||
// Derives the bank paths for one capture: baseName is the sanitized file-stem
|
// Where one render writes, with no index spelling at all: the directory REAPER is
|
||||||
// source, uniqueTag an optional sanitized disambiguator (timestamp/counter) so
|
// told to render into plus the stem/file name it produces there. `absoluteDir` is
|
||||||
// repeated captures don't collide. Produces "<stem>[_<tag>].wav".
|
// 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; // <stem>.wav
|
||||||
|
std::string fileStem; // <stem> (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 "<stem>[_<tag>].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,
|
BankPaths deriveBankPaths(const std::string& projectDir,
|
||||||
const std::string& baseName,
|
const std::string& baseName,
|
||||||
const std::string& uniqueTag);
|
const std::string& uniqueTag);
|
||||||
|
|||||||
@@ -25,4 +25,38 @@ std::vector<int> directChildIndices(const std::vector<int>& folderDepths,
|
|||||||
return children;
|
return children;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SiblingPlacement siblingPlacement(const std::vector<int>& folderDepths, int srcIndex) {
|
||||||
|
const int count = static_cast<int>(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<int> levels(static_cast<std::size_t>(count) + 1, 0);
|
||||||
|
for (int i = 0; i < count; ++i) {
|
||||||
|
const int next = levels[static_cast<std::size_t>(i)] +
|
||||||
|
folderDepths[static_cast<std::size_t>(i)];
|
||||||
|
levels[static_cast<std::size_t>(i) + 1] = next < 0 ? 0 : next;
|
||||||
|
}
|
||||||
|
|
||||||
|
const int L = levels[static_cast<std::size_t>(src)];
|
||||||
|
|
||||||
|
int p = src + 1;
|
||||||
|
if (folderDepths[static_cast<std::size_t>(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<std::size_t>(j)] == L) { p = j; break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SiblingPlacement out;
|
||||||
|
out.insertIndex = p;
|
||||||
|
out.precedingIndex = p - 1;
|
||||||
|
out.precedingDepth = L - levels[static_cast<std::size_t>(p - 1)];
|
||||||
|
out.newDepth = levels[static_cast<std::size_t>(p)] - L;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace reasampler::capture
|
} // namespace reasampler::capture
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
// track_topology — pure folder arithmetic over a project's track list: which tracks
|
// 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
|
// are the DIRECT children of a folder parent, and where a new SIBLING of a given
|
||||||
// alone. NO REAPER types (the shell reads the deltas); unit-tested by
|
// track goes, both derived from the I_FOLDERDEPTH deltas alone. NO REAPER types
|
||||||
// tests/test_track_topology.cpp.
|
// (the shell reads the deltas); unit-tested by tests/test_track_topology.cpp.
|
||||||
|
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
@@ -21,4 +21,34 @@ namespace reasampler::capture {
|
|||||||
std::vector<int> directChildIndices(const std::vector<int>& folderDepths,
|
std::vector<int> directChildIndices(const std::vector<int>& folderDepths,
|
||||||
int parentIndex);
|
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<int>& folderDepths, int srcIndex);
|
||||||
|
|
||||||
} // namespace reasampler::capture
|
} // namespace reasampler::capture
|
||||||
|
|||||||
@@ -65,7 +65,10 @@ settled 2026-07-23):
|
|||||||
play/show so only the active mode's lane is present. Items keep their real
|
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.
|
position and real track — nothing is moved in time or deleted.
|
||||||
- **Membership: adoption rule for new items; active mode for new tracks.** New
|
- **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
|
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
|
spanning exactly one mode, the item adopts that mode; the active-mode
|
||||||
fallback applies only when the track is empty or already spans multiple
|
fallback applies only when the track is empty or already spans multiple
|
||||||
|
|||||||
@@ -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
|
- **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
|
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.
|
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
|
- **`arrange_drop_win` is the only timeline-placing shell IN THIS DIRECTORY** — the
|
||||||
it places because the USER dragged a card onto the arrange. Root `CLAUDE.md`'s
|
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
|
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.
|
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
|
- **Ingest NEVER inserts a timeline item.** Arrange capture→bank→assign reuses the
|
||||||
|
|||||||
@@ -45,9 +45,13 @@ detail not covered there:
|
|||||||
`capture_realtime_shell` cannot block REAPER's UI for the duration of a realtime
|
`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 +
|
record, so `begin`/`tick`/`abort` are async by construction and the temp-track +
|
||||||
send recipe lives in the shell, not the pure core.
|
send recipe lives in the shell, not the pure core.
|
||||||
- **`RunInsertSelected` is the one deliberate exception to capture-never-places**
|
- **This directory hosts TWO placing paths, and neither is a capture placing
|
||||||
(see `capture_orchestrator` below) — every other capture entry point writes only
|
itself.** `RunInsertSelected` (see `capture_orchestrator` below) places a *bank
|
||||||
a file + index entry.
|
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
|
## 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.
|
- `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_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`.
|
- `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.
|
- `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.
|
- `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.
|
- `track_guid` — shared `MediaTrack*` → canonical GUID-string formatter; single source of truth for membership keys.
|
||||||
|
|||||||
@@ -39,6 +39,7 @@
|
|||||||
|
|
||||||
#define REAPERAPI_MINIMAL
|
#define REAPERAPI_MINIMAL
|
||||||
#define REAPERAPI_WANT_EnumProjects
|
#define REAPERAPI_WANT_EnumProjects
|
||||||
|
#define REAPERAPI_WANT_GetProjectPathEx
|
||||||
#define REAPERAPI_WANT_GetSetProjectInfo
|
#define REAPERAPI_WANT_GetSetProjectInfo
|
||||||
#define REAPERAPI_WANT_GetSetProjectInfo_String
|
#define REAPERAPI_WANT_GetSetProjectInfo_String
|
||||||
#define REAPERAPI_WANT_GetSet_LoopTimeRange
|
#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
|
// Compute the tag ONCE — calling makeUniqueTag() twice would let the file
|
||||||
// stem and Sample.id diverge (the counter advances per call).
|
// stem and Sample.id diverge (the counter advances per call).
|
||||||
const std::string uniqueTag = makeUniqueTag("");
|
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<char> recDir(4096, '\0');
|
||||||
|
GetProjectPathEx(proj, recDir.data(), static_cast<int>(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);
|
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.
|
// yield a different value and desync Sample.id from the file name.
|
||||||
s.id = "cap-" + uniqueTag + "-" + paths.fileName;
|
s.id = "cap-" + uniqueTag + "-" + paths.fileName;
|
||||||
s.displayName = request.label();
|
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.sourceMode = request.sourceMode;
|
||||||
s.sourceRange.startSeconds = request.startSeconds;
|
s.sourceRange.startSeconds = request.startSeconds;
|
||||||
s.sourceRange.endSeconds = request.endSeconds;
|
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
|
// single played note, so no root note is derivable; loop points are set
|
||||||
// later by an explicit user action.
|
// later by an explicit user action.
|
||||||
|
|
||||||
result.status = CaptureStatus::Ok;
|
result.status = CaptureStatus::Ok;
|
||||||
result.sample = s;
|
result.sample = s;
|
||||||
|
result.absolutePath = expectedPath;
|
||||||
result.message = "Captured [" +
|
result.message = "Captured [" +
|
||||||
std::to_string(request.startSeconds) + "s, " +
|
std::to_string(request.startSeconds) + "s, " +
|
||||||
std::to_string(request.endSeconds) + "s] -> " +
|
std::to_string(request.endSeconds) + "s] -> " +
|
||||||
paths.relativePath + monoCollapseSuffix(collapseOutcome);
|
(relativePath.empty() ? expectedPath : relativePath) +
|
||||||
|
monoCollapseSuffix(collapseOutcome);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,14 @@ enum class WavBitDepth {
|
|||||||
Float32,
|
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, // <projectDir>/reasampler_bank — every capture path
|
||||||
|
ProjectMedia, // the project's recording path — the render-in-place verb only
|
||||||
|
};
|
||||||
|
|
||||||
// One capture, independent of source mode.
|
// One capture, independent of source mode.
|
||||||
struct CaptureRequest {
|
struct CaptureRequest {
|
||||||
SourceMode sourceMode = SourceMode::MasterMix;
|
SourceMode sourceMode = SourceMode::MasterMix;
|
||||||
@@ -75,6 +83,9 @@ struct CaptureRequest {
|
|||||||
// The one home for that fallback rule; both backends populate Sample::displayName
|
// The one home for that fallback rule; both backends populate Sample::displayName
|
||||||
// from here rather than each spelling the condition out.
|
// from here rather than each spelling the condition out.
|
||||||
std::string label() const { return displayName.empty() ? baseName : displayName; }
|
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.
|
// Every failure is an explicit code, never a thrown exception across the REAPER boundary.
|
||||||
@@ -95,6 +106,11 @@ struct CaptureResult {
|
|||||||
CaptureStatus status = CaptureStatus::RenderFailed;
|
CaptureStatus status = CaptureStatus::RenderFailed;
|
||||||
Sample sample; // valid only when status == Ok
|
Sample sample; // valid only when status == Ok
|
||||||
std::string message; // human-readable detail for the console log
|
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
|
// Deterministic offline-render backend: master mix / time selection / selected
|
||||||
|
|||||||
@@ -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 <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#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<int> folderDepths(ReaProject* proj, int count) {
|
||||||
|
std::vector<int> depths;
|
||||||
|
depths.reserve(static_cast<std::size_t>(count < 0 ? 0 : count));
|
||||||
|
for (int i = 0; i < count; ++i) {
|
||||||
|
MediaTrack* tr = GetTrack(proj, i);
|
||||||
|
depths.push_back(tr ? static_cast<int>(
|
||||||
|
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<char> 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<double>(place.precedingDepth));
|
||||||
|
}
|
||||||
|
SetMediaTrackInfo_Value(fresh, "I_FOLDERDEPTH",
|
||||||
|
static_cast<double>(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<double>(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
|
||||||
@@ -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
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
// Compiled into the reaper_reasampler MODULE, without REAPERAPI_IMPLEMENT (main.cpp
|
// Compiled into the reaper_reasampler MODULE, without REAPERAPI_IMPLEMENT (main.cpp
|
||||||
// owns the API pointers). DAW-verified, not unit-tested.
|
// owns the API pointers). DAW-verified, not unit-tested.
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
#include <map>
|
#include <map>
|
||||||
#include <set>
|
#include <set>
|
||||||
#include <string>
|
#include <string>
|
||||||
@@ -163,11 +164,19 @@ bool detectNewContent() {
|
|||||||
std::map<std::string, std::vector<std::string>> trackItemGuids;
|
std::map<std::string, std::vector<std::string>> trackItemGuids;
|
||||||
enumerateLiveGuids(proj, live, itemOnManualLane, trackItemGuids);
|
enumerateLiveGuids(proj, live, itemOnManualLane, trackItemGuids);
|
||||||
|
|
||||||
const std::vector<std::string> added = g_panel.contentBaseline.observe(live);
|
std::vector<std::string> added = g_panel.contentBaseline.observe(live);
|
||||||
if (added.empty()) return false; // first poll after open, or nothing new this tick
|
if (added.empty()) return false; // first poll after open, or nothing new this tick
|
||||||
|
|
||||||
ViewModeModel& model = g_panel.session->view();
|
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
|
// 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
|
// 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.
|
// mode set — a drop plus its own new siblings must not count each other as prior.
|
||||||
|
|||||||
@@ -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() {
|
int main() {
|
||||||
testStampIsZeroPaddedMonthDayHourMinute();
|
testStampIsZeroPaddedMonthDayHourMinute();
|
||||||
testUnsetStampProducesNoDiscriminator();
|
testUnsetStampProducesNoDiscriminator();
|
||||||
@@ -268,6 +303,12 @@ int main() {
|
|||||||
testOrdinalAndMultiSourceCompose();
|
testOrdinalAndMultiSourceCompose();
|
||||||
testEveryAwkwardStemStaysFilesystemLegal();
|
testEveryAwkwardStemStaysFilesystemLegal();
|
||||||
|
|
||||||
|
testCaptureTrackNamePrefixesAPlainSourceName();
|
||||||
|
testCaptureTrackNameIsIdempotent();
|
||||||
|
testCaptureTrackNameEmptySourceHasNoTrailingSpace();
|
||||||
|
testCaptureTrackNameUnnamedSourceReadsAsCaptureTrackN();
|
||||||
|
testCaptureTrackNameDoesNotMatchAMerePrefixOfTheWord();
|
||||||
|
|
||||||
if (g_fail == 0) std::printf("capture_name: all tests passed\n");
|
if (g_fail == 0) std::printf("capture_name: all tests passed\n");
|
||||||
else std::printf("capture_name: %d CHECK(s) FAILED\n", g_fail);
|
else std::printf("capture_name: %d CHECK(s) FAILED\n", g_fail);
|
||||||
return g_fail ? 1 : 0;
|
return g_fail ? 1 : 0;
|
||||||
|
|||||||
@@ -362,6 +362,39 @@ static void testBankRelativeForNameMatchesDerivePathSpelling() {
|
|||||||
CHECK(bankRelativeForName(p.fileName) == p.relativePath);
|
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() {
|
static void testBankRelativeForNameConventionAndEdge() {
|
||||||
// The convention verbatim: "reasampler_bank/<name>" (the one place the spelling lives).
|
// The convention verbatim: "reasampler_bank/<name>" (the one place the spelling lives).
|
||||||
CHECK(bankRelativeForName("a.wav") == "reasampler_bank/a.wav");
|
CHECK(bankRelativeForName("a.wav") == "reasampler_bank/a.wav");
|
||||||
@@ -396,6 +429,9 @@ int main() {
|
|||||||
testTransitionFirstSaveOfUnsavedRelocatesButPlanNoOps();
|
testTransitionFirstSaveOfUnsavedRelocatesButPlanNoOps();
|
||||||
testTransitionInPlaceSaveIsNoOp();
|
testTransitionInPlaceSaveIsNoOp();
|
||||||
testBankRelativeForNameMatchesDerivePathSpelling();
|
testBankRelativeForNameMatchesDerivePathSpelling();
|
||||||
|
testRenderPathsSpellTheStemExactlyAsTheBankPathDoes();
|
||||||
|
testRenderPathsTakeTheirDirectoryVerbatim();
|
||||||
|
testRenderPathsEmptyDirectoryStaysEmpty();
|
||||||
testBankRelativeForNameConventionAndEdge();
|
testBankRelativeForNameConventionAndEdge();
|
||||||
|
|
||||||
if (g_fail == 0) std::printf("capture_paths: all tests passed\n");
|
if (g_fail == 0) std::printf("capture_paths: all tests passed\n");
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
|
|
||||||
#include "../src/core/capture/track_topology.h"
|
#include "../src/core/capture/track_topology.h"
|
||||||
|
|
||||||
|
#include <cstddef>
|
||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
@@ -79,6 +80,143 @@ static void testSiblingFolderAfterParentClosesIsNotIncluded() {
|
|||||||
CHECK(sameIndices(directChildIndices(depths, 0), {1, 2}));
|
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<int> depthsAfter(const std::vector<int>& depths,
|
||||||
|
const SiblingPlacement& p) {
|
||||||
|
std::vector<int> out = depths;
|
||||||
|
if (p.precedingIndex >= 0) out[static_cast<std::size_t>(p.precedingIndex)] = p.precedingDepth;
|
||||||
|
out.insert(out.begin() + p.insertIndex, p.newDepth);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int sumOf(const std::vector<int>& 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<int>& depths, int idx) {
|
||||||
|
int level = 0;
|
||||||
|
for (int i = 0; i < idx; ++i) level += depths[static_cast<std::size_t>(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<int>& before, int srcIdx) {
|
||||||
|
const SiblingPlacement p = siblingPlacement(before, srcIdx);
|
||||||
|
const std::vector<int> 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<int> 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<int> 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<int> 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<int> 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<int> 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<int> 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<int> 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<int> depths{0, -2, -1};
|
||||||
|
const SiblingPlacement p = siblingPlacement(depths, 1);
|
||||||
|
CHECK(p.insertIndex >= 0 && p.insertIndex <= static_cast<int>(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<int> 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<int> 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() {
|
int main() {
|
||||||
testFlatProjectHasNoChildren();
|
testFlatProjectHasNoChildren();
|
||||||
testFolderParentReturnsItsDirectChildren();
|
testFolderParentReturnsItsDirectChildren();
|
||||||
@@ -88,6 +226,16 @@ int main() {
|
|||||||
testUnterminatedFolderSwallowsTheRest();
|
testUnterminatedFolderSwallowsTheRest();
|
||||||
testSiblingFolderAfterParentClosesIsNotIncluded();
|
testSiblingFolderAfterParentClosesIsNotIncluded();
|
||||||
|
|
||||||
|
testSiblingOfANormalTrackGoesDirectlyBelowIt();
|
||||||
|
testSiblingOfAMidFolderTrackStaysInsideTheFolder();
|
||||||
|
testSiblingOfTheLastTrackInAFolderInheritsTheClosingDelta();
|
||||||
|
testSiblingOfTheLastTrackInTwoFoldersMovesTheWholeClose();
|
||||||
|
testSiblingOfAFolderParentLandsAfterTheWholeFolder();
|
||||||
|
testSiblingOfTheLastTrackInTheProjectAppends();
|
||||||
|
testSiblingOfTheLastTrackInTheProjectInsideAFolder();
|
||||||
|
testMalformedDeltaListClampsRatherThanAsserting();
|
||||||
|
testOutOfRangeSourceIndexClamps();
|
||||||
|
|
||||||
if (g_fail == 0) std::printf("track_topology: all tests passed\n");
|
if (g_fail == 0) std::printf("track_topology: all tests passed\n");
|
||||||
return g_fail == 0 ? 0 : 1;
|
return g_fail == 0 ? 0 : 1;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1739,6 +1739,48 @@ static void testLaneMintingEmptyFolderNotSplit() {
|
|||||||
|
|
||||||
// -- D2.6 JSON round-trip with lane index + membership -----------------------
|
// -- 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<std::string>{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() {
|
static void testLaneJsonRoundTrip() {
|
||||||
ViewModeModel vm;
|
ViewModeModel vm;
|
||||||
CHECK(vm.modes().add(Mode{"mixdown", "Mixdown", 2}));
|
CHECK(vm.modes().add(Mode{"mixdown", "Mixdown", 2}));
|
||||||
@@ -1922,6 +1964,7 @@ int main() {
|
|||||||
testLaneMintingShowBothNotForceSplit();
|
testLaneMintingShowBothNotForceSplit();
|
||||||
testLaneMintingSingleModeLeafVisibleOnceNoSplit();
|
testLaneMintingSingleModeLeafVisibleOnceNoSplit();
|
||||||
testLaneMintingEmptyFolderNotSplit();
|
testLaneMintingEmptyFolderNotSplit();
|
||||||
|
testExplicitArrangeRecordRoundTripsAndBehavesLikeAbsence();
|
||||||
testLaneJsonRoundTrip();
|
testLaneJsonRoundTrip();
|
||||||
testLaneMalformedJson();
|
testLaneMalformedJson();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user