Merge Phase Rho: render in place, a track's output to a new sibling with the source parked on 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,19 @@ 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"). Read
|
||||||
|
// from kCaptureTrackPrefixBare rather than chopped off prefix, so the two names
|
||||||
|
// can't drift out of sync with each other (both expand from the same header token).
|
||||||
|
const std::string bare = kCaptureTrackPrefixBare;
|
||||||
|
|
||||||
|
if (sourceName.empty()) return bare;
|
||||||
|
if (sourceName == bare) return sourceName;
|
||||||
|
if (sourceName.rfind(prefix, 0) == 0) return sourceName;
|
||||||
|
return prefix + sourceName;
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace reasampler::capture
|
} // namespace reasampler::capture
|
||||||
|
|||||||
@@ -59,4 +59,26 @@ std::string formatCaptureStamp(const CaptureStamp& stamp);
|
|||||||
|
|
||||||
CaptureName composeCaptureName(const CaptureNameInputs& in);
|
CaptureName composeCaptureName(const CaptureNameInputs& in);
|
||||||
|
|
||||||
|
// The single source of truth for the word itself — kCaptureTrackPrefixBare and
|
||||||
|
// kCaptureTrackPrefix below both expand from this one token, so editing it can never
|
||||||
|
// desync captureTrackName's "no readable source name" bare-word fallback from the
|
||||||
|
// separator-terminated prefix it is derived from.
|
||||||
|
#define REASAMPLER_CAPTURE_TRACK_WORD "Capture"
|
||||||
|
|
||||||
|
// The bare word behind kCaptureTrackPrefix, needed by captureTrackName's
|
||||||
|
// no-readable-source-name fallback.
|
||||||
|
inline constexpr const char* kCaptureTrackPrefixBare = REASAMPLER_CAPTURE_TRACK_WORD;
|
||||||
|
|
||||||
|
// Prefixed onto a source track's name to name the track a render-in-place created.
|
||||||
|
// A display convention, not a persisted key — unlike a lane prefix or an action-id
|
||||||
|
// suffix, changing it later strands nothing.
|
||||||
|
inline constexpr const char* kCaptureTrackPrefix = REASAMPLER_CAPTURE_TRACK_WORD " ";
|
||||||
|
|
||||||
|
// The new track's name for a render of `sourceName`. IDEMPOTENT — a fixed point on
|
||||||
|
// its own output, so a second render over a result track yields "Capture MONEY"
|
||||||
|
// again rather than "Capture Capture MONEY". A counter suffix is deliberately not
|
||||||
|
// offered: REAPER does not uniquify track names either, and what distinguishes two
|
||||||
|
// renders of one source is their position, not their name.
|
||||||
|
std::string captureTrackName(const std::string& sourceName);
|
||||||
|
|
||||||
} // namespace reasampler::capture
|
} // 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,36 @@ 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. On a well-formed delta list
|
||||||
|
// (one whose deltas sum to zero) the two writes preserve the total delta sum, so no
|
||||||
|
// track after the insertion changes level — the malformed case below does not carry
|
||||||
|
// that guarantee; the clamp keeps the result legal, not level-preserving.
|
||||||
|
//
|
||||||
|
// A malformed list (deltas not summing to zero, an out-of-range srcIndex) CLAMPS to
|
||||||
|
// the nearest legal placement rather than asserting: the failure mode of a corrupt
|
||||||
|
// project must be a track at the wrong nesting level, never a crash.
|
||||||
|
SiblingPlacement siblingPlacement(const std::vector<int>& folderDepths, int srcIndex);
|
||||||
|
|
||||||
} // namespace reasampler::capture
|
} // namespace reasampler::capture
|
||||||
|
|||||||
@@ -64,8 +64,11 @@ settled 2026-07-23):
|
|||||||
- **Mechanism: fixed item lanes.** Map mode → lane; toggle drives per-lane
|
- **Mechanism: fixed item lanes.** Map mode → lane; toggle drives per-lane
|
||||||
play/show so only the active mode's lane is present. Items keep their real
|
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 absent an
|
||||||
tracks are tagged to the active mode at creation. New items follow an
|
explicit tag.** New tracks are tagged to the active mode at creation **only when the GUID carries no
|
||||||
|
membership record** — an explicit tag wins over the detector, because the detector
|
||||||
|
classifies content the *user* made, not content the tool made and already
|
||||||
|
classified. New items follow an
|
||||||
adoption rule: if the item's track has pre-existing managed-eligible content
|
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,24 +45,29 @@ 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
|
||||||
|
|
||||||
- `capture` — two CONCRETE backends with deliberately different lifecycles (no shared interface — the former `ICaptureBackend` was deleted in Q-W3, T4-26: one deriver, zero polymorphic call sites): `OfflineRenderBackend` (deterministic default, synchronous) and `RealtimeRecordBackend` (async begin/tick/abort). Input: `CaptureRequest`. Output: finished file + populated `Sample` handed to `bank_model`. It also owns the two file-side steps both backends share, in this order: `collapseCapturedFileToMono` (the lossless mono collapse, applied to the landed file) and `stampCaptureSample`, which measures the channel count off that same file so the entry and the audio cannot disagree. And `captureNameFor` — the impure local-clock read the entry points call to build a request's label + stem, kept out of the pure `core/capture/capture_name` composition it feeds.
|
- `capture` — two CONCRETE backends with deliberately different lifecycles (no shared interface — the former `ICaptureBackend` was deleted in Q-W3, T4-26: one deriver, zero polymorphic call sites): `OfflineRenderBackend` (deterministic default, synchronous) and `RealtimeRecordBackend` (async begin/tick/abort). Input: `CaptureRequest`. Output: finished file + populated `Sample` — destination-dependent: on `CaptureDestination::Bank` (the default) the `Sample` is handed to `bank_model`; on `ProjectMedia` the file lands outside the bank and the caller (`render_in_place`) discards the returned `Sample`. It also owns the two file-side steps both backends share, in this order: `collapseCapturedFileToMono` (the lossless mono collapse, applied to the landed file) and `stampCaptureSample`, which measures the channel count off that same file so the entry and the audio cannot disagree. And `captureNameFor` — the impure local-clock read the entry points call to build a request's label + stem, kept out of the pure `core/capture/capture_name` composition it feeds.
|
||||||
- `render_bounds_gate` (`shell/capture`) — the exact-bounds verdict on a landed offline render and the refusal's file handling, split off `capture.cpp` on the render-vs-judge seam. Refuses a frame count that is not the window's AND a file whose frames cannot be measured at all (an invalid layout used to skip the gate and land with an unknown channel count). Judges `TailMode::None` only — Auto/Manual add frames by design, and an unmeasurable render still lands under those two (`docs/TODO.md`). A refused render is MOVED to `<projectDir>/reasampler_refused/` rather than deleted, so the frames it did print survive for diagnosis while the short-render root cause is open; the bank never INDEXES it either way — but a failed move leaves the file sitting unindexed in the bank folder itself, not `reasampler_refused/` (the console message says which happened).
|
- `render_bounds_gate` (`shell/capture`) — the exact-bounds verdict on a landed offline render and the refusal's file handling, split off `capture.cpp` on the render-vs-judge seam. Refuses a frame count that is not the window's AND a file whose frames cannot be measured at all (an invalid layout used to skip the gate and land with an unknown channel count). Judges `TailMode::None` only — Auto/Manual add frames by design, and an unmeasurable render still lands under those two (`docs/TODO.md`). Refusal handling is destination-aware (`render_bounds_gate.h`): on `CaptureDestination::Bank`, a refused render is MOVED to `<projectDir>/reasampler_refused/` rather than deleted, so the frames it did print survive for diagnosis while the short-render root cause is open — but a failed move leaves the file sitting unindexed in the bank folder itself, not `reasampler_refused/` (the console message says which happened); the bank never INDEXES it either way. On `CaptureDestination::ProjectMedia` the file is left exactly where the renderer wrote it — no move, no bank folder, no bank language in the message — because that render is the project's own media, not the tool's (`docs/product/render-in-place.md` "Where the file goes").
|
||||||
- `scope_resolve` (`shell/capture`) — scope/source resolution shared by every capture entry point (Q-W3 hoist out of `main.cpp`): razor-else-time range inference, selected-track/selected-item-owning-track collection with canonical GUIDs, and the M10 provenance-assembly inputs (read BEFORE the FX-bypass guard neutralizes the in-scope chain). Also the one place a source track's NAME is read (`trackName`, via `GetTrackName` — chosen over `P_NAME` because it already answers REAPER's `"Track N"` convention for an unnamed track), landed on `ResolvedSource::trackNames` parallel to `sourceTracks` and composed into the capture's label + stem by the pure `core/capture/capture_name`.
|
- `scope_resolve` (`shell/capture`) — scope/source resolution shared by every capture entry point (Q-W3 hoist out of `main.cpp`): razor-else-time range inference, selected-track/selected-item-owning-track collection with canonical GUIDs, and the M10 provenance-assembly inputs (read BEFORE the FX-bypass guard neutralizes the in-scope chain). Also the one place a source track's NAME is read (`trackName`, via `GetTrackName` — chosen over `P_NAME` because it already answers REAPER's `"Track N"` convention for an unnamed track), landed on `ResolvedSource::trackNames` parallel to `sourceTracks` and composed into the capture's label + stem by the pure `core/capture/capture_name`.
|
||||||
- `render_selection` (`shell/capture`) — the transient track selection a selected-tracks render (`&128`) requires, as a stack RAII guard: REAPER prints whatever tracks are selected, so `renderOffline` makes the request's own tracks BE the selection for the render's duration and restores the user's set on every exit path. Engaged ONLY for that source mode, which leaves a stated residual: a `&32` selected-items render still prints whatever ITEMS the user has selected. Live captures are unaffected (that selection is the source), but a recipe replay of a `SelectedItems` capture renders against whatever happens to be selected then — the recipe stores tracks and a range, never item GUIDs, so this guard cannot close it. Filed in `docs/TODO.md`.
|
- `render_selection` (`shell/capture`) — the transient track selection a selected-tracks render (`&128`) requires, as a stack RAII guard: REAPER prints whatever tracks are selected, so `renderOffline` makes the request's own tracks BE the selection for the render's duration and restores the user's set on every exit path. Engaged ONLY for that source mode, which leaves a stated residual: a `&32` selected-items render still prints whatever ITEMS the user has selected. Live captures are unaffected (that selection is the source), but a recipe replay of a `SelectedItems` capture renders against whatever happens to be selected then — the recipe stores tracks and a range, never item GUIDs, so this guard cannot close it. Filed in `docs/TODO.md`.
|
||||||
- `render_isolation` (`shell/capture`) — the transient upstream silencing a ranged ITEM render needs, as a stack RAII guard alongside the two above: the selected-tracks source prints everything flowing INTO the track, so each direct folder child's `B_MAINSEND` and each of the track's receives' `B_MUTE` are cut for the render and restored on every exit path. Direct children only — a grandchild reaches the track through the child that owns it. The child-set walk is pure (`core/capture/track_topology`).
|
- `render_isolation` (`shell/capture`) — the transient upstream silencing a ranged ITEM render needs, as a stack RAII guard alongside the two above: the selected-tracks source prints everything flowing INTO the track, so each direct folder child's `B_MAINSEND` and each of the track's receives' `B_MUTE` are cut for the render and restored on every exit path. Direct children only — a grandchild reaches the track through the child that owns it. The child-set walk is pure (`core/capture/track_topology`).
|
||||||
- `capture_orchestrator` (`shell/capture`) — single-capture orchestration + the realtime/insert action bodies (Q-W3 hoist, T4-02): `renderOffline` (one offline render under the scope's FX-bypass guard), `captureAndIndexOne` (render + provenance stamp + bank add + tracking-ledger record, unpersisted), `RunCapture`/`RunCaptureItemAssign`, `RunCaptureRealtimeTrack`/`RunCancelRealtime` (the realtime action bodies — the in-flight state lives in `realtime_lifecycle`), and `RunInsertSelected` (the ONE deliberate exception to capture-never-places).
|
- `capture_orchestrator` (`shell/capture`) — single-capture orchestration + the realtime/insert action bodies (Q-W3 hoist, T4-02): `renderOffline` (one offline render under the scope's FX-bypass guard), `captureAndIndexOne` (render + provenance stamp + bank add + tracking-ledger record, unpersisted), `RunCapture`/`RunCaptureItemAssign`, `RunCaptureRealtimeTrack`/`RunCancelRealtime` (the realtime action bodies — the in-flight state lives in `realtime_lifecycle`), and `RunInsertSelected` (the capture family's deliberate exception to capture-never-places — see the Invariants section above for `render_in_place`, the directory's other placing path, which sits outside the capture family entirely).
|
||||||
- `bake_land` (`shell/capture`) — the EXTENSION's half of the resample chain, the SCAN PASS: scans every open project tab for pending `rsbake_*` requests, lands the ones belonging to the project this session has loaded (via `bake_landing`, below), and refuses the rest with `WrongProject` — one undo point for the batch, each answered over its own key inside the invoking instance's synchronous action call. It owns every ext-state read and write in the chain. The per-key verdict itself is NOT this TU's: it is `core/wire`'s pure `classifyBakeScan`, so this shell only enumerates, reads, and applies — counting every verdict into a `wire::BakeScanTally` as it goes, printing `wire::describeBakeKey` for EVERY enumerated key (the only thing that names which key is whose) plus `wire::describeBakeScan` whenever any key went unanswered or any answer's write was not confirmed, in one `ShowConsoleMsg`. It PROVES every write — answer or stale-clear — by reading the key back (`wire::extStateWriteLanded`, whose home is `core/wire/ext_state_read.h`); an answer that did not land is the one no-answer the tally alone cannot show. That proof is three-valued (`wire::BakeWriteProof`): a read-back that overflowed, or a throw AFTER the `SetProjExtState` call, reports Unknown; a throw BEFORE it reports Rejected, because the write is then known not to have been made. Each key is materialized before any answer is written, so no `SetProjExtState` in this action mutates a set the enumerator is still walking. Answers are held UNENCODED until after the pass's single persist, so a landing whose pass never got its persist through is answered as a failure rather than as an `Ok` no reload would honour — `wire::bakeLandingAfterPersist` is the ONE route to a `Banked` landing, and no path here (dedup included) may assign that word itself. The undo block is stack RAII (`UndoBlock`). Both loops are guarded: a throw in the scan still writes the answers already prepared, and a throw in the write-back loop still prints the lines already accumulated — no path through this action can end in a silent console. It RENDERS NOTHING — the instrument already did, through its own engine in its own process, which is what makes the baked audio the sound the user approved and what keeps the voice engine out of the extension's link graph.
|
- `bake_land` (`shell/capture`) — the EXTENSION's half of the resample chain, the SCAN PASS: scans every open project tab for pending `rsbake_*` requests, lands the ones belonging to the project this session has loaded (via `bake_landing`, below), and refuses the rest with `WrongProject` — one undo point for the batch, each answered over its own key inside the invoking instance's synchronous action call. It owns every ext-state read and write in the chain. The per-key verdict itself is NOT this TU's: it is `core/wire`'s pure `classifyBakeScan`, so this shell only enumerates, reads, and applies — counting every verdict into a `wire::BakeScanTally` as it goes, printing `wire::describeBakeKey` for EVERY enumerated key (the only thing that names which key is whose) plus `wire::describeBakeScan` whenever any key went unanswered or any answer's write was not confirmed, in one `ShowConsoleMsg`. It PROVES every write — answer or stale-clear — by reading the key back (`wire::extStateWriteLanded`, whose home is `core/wire/ext_state_read.h`); an answer that did not land is the one no-answer the tally alone cannot show. That proof is three-valued (`wire::BakeWriteProof`): a read-back that overflowed, or a throw AFTER the `SetProjExtState` call, reports Unknown; a throw BEFORE it reports Rejected, because the write is then known not to have been made. Each key is materialized before any answer is written, so no `SetProjExtState` in this action mutates a set the enumerator is still walking. Answers are held UNENCODED until after the pass's single persist, so a landing whose pass never got its persist through is answered as a failure rather than as an `Ok` no reload would honour — `wire::bakeLandingAfterPersist` is the ONE route to a `Banked` landing, and no path here (dedup included) may assign that word itself. The undo block is stack RAII (`UndoBlock`). Both loops are guarded: a throw in the scan still writes the answers already prepared, and a throw in the write-back loop still prints the lines already accumulated — no path through this action can end in a silent console. It RENDERS NOTHING — the instrument already did, through its own engine in its own process, which is what makes the baked audio the sound the user approved and what keeps the voice engine out of the extension's link graph.
|
||||||
- `bake_landing` (`shell/capture`) — landing ONE bake request, split off `bake_land` on the one-request / whole-pass seam; touches no REAPER API at all. Non-mutating `prepareLanding` and mutating `commitLanding` sit under separate catches in `attemptLanding` — a throw before anything was written is a clean refusal, a throw after it is reported as possibly partial. Replace-vs-add comes from `tracking::resampleLanding`; a replace keeps the entry's id and slot and never deletes the superseded file. Hash-dedup applies on the add path only, before the disk write, matching `updateSampleInPlace`'s "an in-place refresh is not an insert" — and a dedup hit still rides the pass's persist, because the entry it points at may be one the same pass just added. A refused index withdraws the bytes this call had just written — the self-cleanup carve-out from prune's deletion authority, stated in `prune_fs.cpp`'s header. It never persists: the pass does that once for its whole batch, which is why no landing may report itself as banked.
|
- `bake_landing` (`shell/capture`) — landing ONE bake request, split off `bake_land` on the one-request / whole-pass seam; touches no REAPER API at all. Non-mutating `prepareLanding` and mutating `commitLanding` sit under separate catches in `attemptLanding` — a throw before anything was written is a clean refusal, a throw after it is reported as possibly partial. Replace-vs-add comes from `tracking::resampleLanding`; a replace keeps the entry's id and slot and never deletes the superseded file. Hash-dedup applies on the add path only, before the disk write, matching `updateSampleInPlace`'s "an in-place refresh is not an insert" — and a dedup hit still rides the pass's persist, because the entry it points at may be one the same pass just added. A refused index withdraws the bytes this call had just written — the self-cleanup carve-out from prune's deletion authority, stated in `prune_fs.cpp`'s header. It never persists: the pass does that once for its whole batch, which is why no landing may report itself as banked.
|
||||||
- `capture_batch` (`shell/capture`) — the batch-capture family + re-capture-from-source (Q-W3 hoist, T4-02): `RunBatchCaptureItems` (one sample per selected item), `RunBatchCaptureRazor` (one sample per razor area), `RunRecaptureFromSource` (regenerate a provenanced sample from its recorded source's current state, bank-only). Every unit routes through `capture_orchestrator` so every precision invariant holds; persist is batched to one ext-state write per action.
|
- `capture_batch` (`shell/capture`) — the batch-capture family + re-capture-from-source (Q-W3 hoist, T4-02): `RunBatchCaptureItems` (one sample per selected item), `RunBatchCaptureRazor` (one sample per razor area), `RunRecaptureFromSource` (regenerate a provenanced sample from its recorded source's current state, bank-only). Every unit routes through `capture_orchestrator` so every precision invariant holds; persist is batched to one ext-state write per action.
|
||||||
- `realtime_lifecycle` (`shell/capture`) — the in-flight realtime-capture state machine + globals (Q-W3 hoist): the action starts it, `OnTimer` drives it per tick via `DriveRealtimeCapture` (a single-pointer-test idle fast path — load-bearing hot-path guardrail), `CommitRealtimeResult` lands a finished capture in the bank, `AbortRealtimeCaptureForUnload` tears down cleanly on extension unload.
|
- `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 =
|
|
||||||
|
// 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);
|
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);
|
||||||
|
|
||||||
@@ -539,7 +561,7 @@ CaptureResult OfflineRenderBackend::capture(const CaptureRequest& request) {
|
|||||||
// legitimately produce zero (docs/TODO.md "0-byte render" entry: before this check,
|
// legitimately produce zero (docs/TODO.md "0-byte render" entry: before this check,
|
||||||
// Auto/Manual landed an empty file as CaptureStatus::Ok with channelCount == 0).
|
// Auto/Manual landed an empty file as CaptureStatus::Ok with channelCount == 0).
|
||||||
const BoundsVerdict emptyVerdict =
|
const BoundsVerdict emptyVerdict =
|
||||||
checkRenderedFileNotEmpty(expectedPath, projectDir);
|
checkRenderedFileNotEmpty(expectedPath, projectDir, request.destination);
|
||||||
if (emptyVerdict.refused) {
|
if (emptyVerdict.refused) {
|
||||||
result.status = CaptureStatus::BoundsMismatch;
|
result.status = CaptureStatus::BoundsMismatch;
|
||||||
result.message = emptyVerdict.message;
|
result.message = emptyVerdict.message;
|
||||||
@@ -575,7 +597,7 @@ CaptureResult OfflineRenderBackend::capture(const CaptureRequest& request) {
|
|||||||
// yield a different value and desync Sample.id from the file name.
|
// 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;
|
||||||
@@ -592,10 +614,12 @@ CaptureResult OfflineRenderBackend::capture(const CaptureRequest& request) {
|
|||||||
|
|
||||||
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
|
||||||
|
|||||||
@@ -45,6 +45,25 @@ std::string retainRefusedRender(const std::string& renderedPath,
|
|||||||
renderedPath + ", indexed by nothing. Delete it when done.";
|
renderedPath + ", indexed by nothing. Delete it when done.";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ProjectMedia is the project's own media, never the bank's (docs/product/render-in-place.md
|
||||||
|
// "Where the file goes") -- a refusal takes no custody of it. No move, no bank folder, no
|
||||||
|
// mention of a bank the render was never headed for.
|
||||||
|
std::string leaveRefusedRenderInPlace(const std::string& renderedPath) {
|
||||||
|
return " The render was left where it was written, at " + renderedPath +
|
||||||
|
" -- delete it when done.";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dispatches the refusal's file-handling sentence by destination, so both verdict
|
||||||
|
// functions below state one true thing about the file rather than the bank sentence
|
||||||
|
// on every destination.
|
||||||
|
std::string refusalOutcome(const std::string& renderedPath,
|
||||||
|
const std::string& projectDir,
|
||||||
|
CaptureDestination destination) {
|
||||||
|
if (destination == CaptureDestination::ProjectMedia)
|
||||||
|
return leaveRefusedRenderInPlace(renderedPath);
|
||||||
|
return retainRefusedRender(renderedPath, projectDir);
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
std::string refusedRenderFolder(const std::string& projectDir) {
|
std::string refusedRenderFolder(const std::string& projectDir) {
|
||||||
@@ -52,7 +71,8 @@ std::string refusedRenderFolder(const std::string& projectDir) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
BoundsVerdict checkRenderedFileNotEmpty(const std::string& renderedPath,
|
BoundsVerdict checkRenderedFileNotEmpty(const std::string& renderedPath,
|
||||||
const std::string& projectDir) {
|
const std::string& projectDir,
|
||||||
|
CaptureDestination destination) {
|
||||||
BoundsVerdict v;
|
BoundsVerdict v;
|
||||||
std::error_code ec;
|
std::error_code ec;
|
||||||
const std::uintmax_t size = std::filesystem::file_size(renderedPath, ec);
|
const std::uintmax_t size = std::filesystem::file_size(renderedPath, ec);
|
||||||
@@ -61,7 +81,7 @@ BoundsVerdict checkRenderedFileNotEmpty(const std::string& renderedPath,
|
|||||||
v.refused = true;
|
v.refused = true;
|
||||||
v.message = "Render at " + renderedPath + " is 0 bytes -- REAPER produced an empty "
|
v.message = "Render at " + renderedPath + " is 0 bytes -- REAPER produced an empty "
|
||||||
"file, so there is nothing to check the requested range against." +
|
"file, so there is nothing to check the requested range against." +
|
||||||
retainRefusedRender(renderedPath, projectDir);
|
refusalOutcome(renderedPath, projectDir, destination);
|
||||||
return v;
|
return v;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,7 +109,7 @@ BoundsVerdict checkRenderedBounds(const std::string& renderedPath,
|
|||||||
"not be read (locked, missing, or a permissions error), its WAV "
|
"not be read (locked, missing, or a permissions error), its WAV "
|
||||||
"header did not parse, or it declared no sample rate -- so the "
|
"header did not parse, or it declared no sample rate -- so the "
|
||||||
"frames it holds were never checked against the requested range." +
|
"frames it holds were never checked against the requested range." +
|
||||||
source + retainRefusedRender(renderedPath, projectDir);
|
source + refusalOutcome(renderedPath, projectDir, request.destination);
|
||||||
return v;
|
return v;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,7 +144,7 @@ BoundsVerdict checkRenderedBounds(const std::string& renderedPath,
|
|||||||
std::to_string(request.endSeconds) + "s) -> frame indices [" +
|
std::to_string(request.endSeconds) + "s) -> frame indices [" +
|
||||||
std::to_string(std::llround(request.startSeconds * rate)) + ", " +
|
std::to_string(std::llround(request.startSeconds * rate)) + ", " +
|
||||||
std::to_string(std::llround(request.endSeconds * rate)) + ")." +
|
std::to_string(std::llround(request.endSeconds * rate)) + ")." +
|
||||||
msNote + retainRefusedRender(renderedPath, projectDir);
|
msNote + refusalOutcome(renderedPath, projectDir, request.destination);
|
||||||
return v;
|
return v;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,9 +10,11 @@
|
|||||||
|
|
||||||
namespace reasampler::capture {
|
namespace reasampler::capture {
|
||||||
|
|
||||||
// A refused render is MOVED out of the bank, not deleted: while the root cause of a
|
// On the Bank destination, a refused render is MOVED out of the bank, not deleted: while
|
||||||
// short render is open (docs/TODO.md), the frames it did print are the evidence — and
|
// the root cause of a short render is open (docs/TODO.md), the frames it did print are
|
||||||
// nothing may index a file the bank never accepted.
|
// the evidence — and nothing may index a file the bank never accepted. On ProjectMedia,
|
||||||
|
// the render is the project's own media (docs/product/render-in-place.md "Where the file
|
||||||
|
// goes"), so a refusal leaves it exactly where it was written — no move, no bank folder.
|
||||||
struct BoundsVerdict {
|
struct BoundsVerdict {
|
||||||
bool refused = false;
|
bool refused = false;
|
||||||
std::string message; // console text; meaningful only when refused
|
std::string message; // console text; meaningful only when refused
|
||||||
@@ -22,7 +24,8 @@ struct BoundsVerdict {
|
|||||||
// frame count is not the window's (render_window::renderHonoredBounds owns the
|
// frame count is not the window's (render_window::renderHonoredBounds owns the
|
||||||
// tolerance and its limits), or the file cannot be measured at all — an unmeasured
|
// tolerance and its limits), or the file cannot be measured at all — an unmeasured
|
||||||
// render is not a verified one. TailMode::Auto/Manual add frames by design and are
|
// render is not a verified one. TailMode::Auto/Manual add frames by design and are
|
||||||
// never judged here. `projectDir` is where a refused render is parked.
|
// never judged here. `projectDir` and `request.destination` together decide where a
|
||||||
|
// refused render is parked.
|
||||||
BoundsVerdict checkRenderedBounds(const std::string& renderedPath,
|
BoundsVerdict checkRenderedBounds(const std::string& renderedPath,
|
||||||
const std::string& projectDir,
|
const std::string& projectDir,
|
||||||
const CaptureRequest& request);
|
const CaptureRequest& request);
|
||||||
@@ -31,11 +34,12 @@ BoundsVerdict checkRenderedBounds(const std::string& renderedPath,
|
|||||||
// never legitimately produce zero), independent of and ahead of the TailMode::None-only
|
// never legitimately produce zero), independent of and ahead of the TailMode::None-only
|
||||||
// gate above, which does not run on Auto/Manual at all.
|
// gate above, which does not run on Auto/Manual at all.
|
||||||
BoundsVerdict checkRenderedFileNotEmpty(const std::string& renderedPath,
|
BoundsVerdict checkRenderedFileNotEmpty(const std::string& renderedPath,
|
||||||
const std::string& projectDir);
|
const std::string& projectDir,
|
||||||
|
CaptureDestination destination);
|
||||||
|
|
||||||
// Where a refused render is retained -- exposed so a multi-unit caller (batch capture)
|
// Where a refused Bank-destination render is retained -- exposed so a multi-unit caller
|
||||||
// can name the folder once without duplicating the subfolder name `checkRenderedBounds`
|
// (batch capture, Bank-only) can name the folder once without duplicating the subfolder
|
||||||
// and `checkRenderedFileNotEmpty` already use internally.
|
// name `checkRenderedBounds` and `checkRenderedFileNotEmpty` already use internally.
|
||||||
std::string refusedRenderFolder(const std::string& projectDir);
|
std::string refusedRenderFolder(const std::string& projectDir);
|
||||||
|
|
||||||
} // namespace reasampler::capture
|
} // namespace reasampler::capture
|
||||||
|
|||||||
@@ -0,0 +1,231 @@
|
|||||||
|
// render_in_place.cpp — see render_in_place.h.
|
||||||
|
//
|
||||||
|
// Includes reaper_plugin_functions.h WITHOUT REAPERAPI_IMPLEMENT — main.cpp is the
|
||||||
|
// one TU that defines the API pointers; here they are extern.
|
||||||
|
|
||||||
|
#include "shell/capture/render_in_place.h"
|
||||||
|
|
||||||
|
#include <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; the render landed at " +
|
||||||
|
res.absolutePath + " but was not placed.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SiblingPlacement place =
|
||||||
|
siblingPlacement(folderDepths(proj, trackCount), srcIndex);
|
||||||
|
|
||||||
|
// Read ONCE, and only to reapply the mode / decide whether the result landed
|
||||||
|
// visible — never to choose a tag. Both tags below are absolute.
|
||||||
|
const std::string activeMode = session.view().activeModeId();
|
||||||
|
|
||||||
|
Undo_BeginBlock2(nullptr);
|
||||||
|
|
||||||
|
// flags = 0, never 1: flags&1 adds default envelopes/FX, and a default chain
|
||||||
|
// would process a render that already carries the source's FX a second time.
|
||||||
|
InsertTrackInProject(proj, place.insertIndex, /*flags=*/0);
|
||||||
|
MediaTrack* fresh = GetTrack(proj, place.insertIndex);
|
||||||
|
if (!fresh) {
|
||||||
|
// InsertTrackInProject already mutated the project by this point, so the
|
||||||
|
// "no ext-state write -> discard" idiom does not apply here — a discard would
|
||||||
|
// leave the orphaned track un-undoable.
|
||||||
|
Undo_EndBlock2(nullptr, "ReaSampler: render in place (failed to create result track)",
|
||||||
|
-1);
|
||||||
|
refuse("could not create the result track; the render landed at " +
|
||||||
|
res.absolutePath + " but was not placed.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Both writes or none — one alone lands the new track at the wrong nesting level,
|
||||||
|
// which is audible in both directions (see siblingPlacement).
|
||||||
|
if (place.precedingIndex >= 0) {
|
||||||
|
if (MediaTrack* preceding = GetTrack(proj, place.precedingIndex))
|
||||||
|
SetMediaTrackInfo_Value(preceding, "I_FOLDERDEPTH",
|
||||||
|
static_cast<double>(place.precedingDepth));
|
||||||
|
}
|
||||||
|
SetMediaTrackInfo_Value(fresh, "I_FOLDERDEPTH",
|
||||||
|
static_cast<double>(place.newDepth));
|
||||||
|
|
||||||
|
// GetTrackColor returns the colour already OR'd with 0x1000000 and 0 for "no
|
||||||
|
// colour set", which I_CUSTOMCOLOR reads as unused — so one line clones a colour
|
||||||
|
// and the absence of one, with no branch.
|
||||||
|
SetMediaTrackInfo_Value(fresh, "I_CUSTOMCOLOR",
|
||||||
|
static_cast<double>(GetTrackColor(source)));
|
||||||
|
const std::string freshName = captureTrackName(trackName(source));
|
||||||
|
setTrackName(fresh, freshName);
|
||||||
|
|
||||||
|
// After every attribute write, per the SDK header's manual-panel-update caveat.
|
||||||
|
TrackList_AdjustWindows(false);
|
||||||
|
|
||||||
|
// Unsnapped and unrounded, deliberately: this placement IS the null test performed
|
||||||
|
// automatically, so snapping it to the grid would move the audio off the position
|
||||||
|
// it was rendered from. InsertOptions{} defaults give native length and no conform.
|
||||||
|
const double cursorPos = GetCursorPosition();
|
||||||
|
SetOnlyTrackSelected(fresh);
|
||||||
|
SetEditCurPos(src.startSeconds, false, false);
|
||||||
|
// InsertMedia's int return isn't SDK-documented; treated conservatively as
|
||||||
|
// 0 = failure, matching performArrangeDrop — an empty result track would
|
||||||
|
// otherwise be a silent no-op, which is exactly what this verb must not produce.
|
||||||
|
const bool placed =
|
||||||
|
InsertMedia(res.absolutePath.c_str(), computeInsertMode(InsertOptions{})) != 0;
|
||||||
|
SetEditCurPos(cursorPos, false, false);
|
||||||
|
// The new track is left selected, alone — in the headline case the source is being
|
||||||
|
// parked out of sight in the same gesture, so restoring the selection would leave
|
||||||
|
// the user selecting an invisible track.
|
||||||
|
|
||||||
|
// Absolute, not mode-following: the source parks on the bench, the result is an
|
||||||
|
// Arrange member whatever mode was active. Explicit records rather than untag(),
|
||||||
|
// because the record is what the panel's auto-tag detector defers to.
|
||||||
|
MembershipIndex& membership = session.view().membership();
|
||||||
|
membership.tag(guidString(source), kDesignModeId);
|
||||||
|
membership.tag(guidString(fresh), kArrangeModeId);
|
||||||
|
|
||||||
|
// The track is brand new, so its items are exactly the ones just placed. An
|
||||||
|
// untagged item would be handed to the detector, which tags to the active mode.
|
||||||
|
const int itemCount = CountTrackMediaItems(fresh);
|
||||||
|
for (int i = 0; i < itemCount; ++i) {
|
||||||
|
if (MediaItem* it = GetTrackMediaItem(fresh, i)) {
|
||||||
|
const std::string ig = itemGuid(it);
|
||||||
|
if (!ig.empty()) membership.tag(ig, kArrangeModeId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mintManagedLanes(session.view(), nullptr);
|
||||||
|
applyMode(session.view(), activeMode, nullptr); // a reapply, never a switch
|
||||||
|
|
||||||
|
Undo_EndBlock2(nullptr, "ReaSampler: render selected track to a new track", -1);
|
||||||
|
|
||||||
|
// Persist outside the block. The offline render's own save gate already forced a
|
||||||
|
// saved project, so the Save-As-guarded persist the Design View actions need
|
||||||
|
// cannot have anything to prompt for here.
|
||||||
|
session.saveToActiveProject();
|
||||||
|
|
||||||
|
if (!placed) {
|
||||||
|
refuse("the render landed at " + res.absolutePath +
|
||||||
|
" but REAPER refused to place it — the new track is empty.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Silent on success — the new track is the feedback. Except when it is not: fired
|
||||||
|
// outside Arrange the result track is parked, so a silent success would be
|
||||||
|
// indistinguishable from a no-op.
|
||||||
|
if (activeMode != kArrangeModeId) {
|
||||||
|
ShowConsoleMsg(("ReaSampler render in place: created \"" + freshName +
|
||||||
|
"\" in Arrange (switch to Arrange to see it).\n")
|
||||||
|
.c_str());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace reasampler::capture
|
||||||
@@ -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