Merge: D2 W3-A — mint managed lanes to separate cross-mode content per stance

This commit is contained in:
2026-07-23 20:17:27 -04:00
11 changed files with 673 additions and 4 deletions
+4
View File
@@ -63,6 +63,10 @@ target_include_directories(mode_switch PUBLIC src)
# ---------------------------------------------------------------------------
add_library(view_mode_model STATIC src/view_mode_model.cpp)
target_include_directories(view_mode_model PUBLIC src)
# The pure lane-minting decision (planLaneMinting) names managed lanes via the ONE
# durable-key convention in lane_keys (laneNameForMode), so the model depends on that
# pure sibling. PUBLIC so every consumer (tests + module) resolves the symbol.
target_link_libraries(view_mode_model PUBLIC lane_keys)
# ---------------------------------------------------------------------------
# 2d) Pure view_tree library — NO REAPER, NO SWELL. The one testable-outside-DAW
+19 -4
View File
@@ -753,8 +753,11 @@ void enumerateLiveGuids(ReaProject* proj, std::set<std::string>& allGuids,
// persist.cpp writes it on the next project save alongside the bank and view state, the
// same way an action-driven tag is persisted. Wrapping this in an Undo block would flood
// the REAPER undo history with a new entry for every timer tick that sees new content.
void detectNewContent() {
if (!g_panel.session) return;
// Returns true iff this tick tagged at least one new GUID into a mode — the signal the
// caller uses to decide whether to run the lane-minting pass (a track can only newly
// become multi-mode when auto-tag just placed content on it). No tag ⇒ nothing to mint.
bool detectNewContent() {
if (!g_panel.session) return false;
ReaProject* proj = EnumProjects(-1, nullptr, 0);
@@ -778,7 +781,7 @@ void detectNewContent() {
enumerateLiveGuids(proj, live, itemOnManualLane);
const std::vector<std::string> added = g_panel.contentBaseline.observe(live);
if (added.empty()) return; // first poll after open, or nothing new this tick
if (added.empty()) return false; // first poll after open, or nothing new this tick
// Split the new GUIDs into tracks vs items so the pure decision can apply the
// manual-lane exemption to items only. A GUID present in the item-lane map is an
@@ -799,6 +802,7 @@ void detectNewContent() {
autoTagNewContent(newTracks, newItems, model.activeModeId());
for (const AutoTag& tag : tags)
model.membership().tag(tag.guid, tag.modeId);
return !tags.empty();
}
// --- Audition preview ---------------------------------------------------------
@@ -1245,7 +1249,18 @@ void bankPanelRefresh() {
// tracks/items are created in the arrange view, not the panel, so detection must
// not be gated on the dock being visible. READ-ONLY on the project; only mutates
// the in-memory membership index (persist saves it like any action-driven tag).
detectNewContent();
const bool tagged = detectNewContent();
// Lane minting (D2 Wave 3) runs ONLY when detection just tagged new content — a
// track can only newly become multi-mode when auto-tag placed content on it. Unlike
// the invisible membership tag above, minting is a visible structural mutation
// (I_FREEMODE/I_FIXEDLANE/P_LANENAME), so mintManagedLanes wraps it in its own Undo
// block and only mints for tracks that hold >1 mode's content — a single-mode track
// is left to D1 whole-track parking. Managed lanes only; manual lanes untouched.
if (tagged && g_panel.session) {
ReaProject* proj = EnumProjects(-1, nullptr, 0);
mintManagedLanes(g_panel.session->view(), proj);
}
if (!g_panel.open || !g_panel.hwnd) return;
// Repaint only when the bank actually changed (generation bump). Cheap tick
+7
View File
@@ -31,6 +31,13 @@ std::string laneNameForMode(const std::string& modeId) {
return std::string(kManagedLanePrefix) + modeId;
}
std::optional<std::string> modeIdFromLaneName(const std::string& laneName) {
if (!hasManagedPrefix(laneName)) return std::nullopt; // manual/unnamed ⇒ no mode
const std::size_t n = std::strlen(kManagedLanePrefix);
if (laneName.size() == n) return std::nullopt; // prefix only, no mode suffix (illegal)
return laneName.substr(n);
}
bool isOnManualLane(bool isFixedLaneTrack, const std::string& laneName) {
// On a normal (non-fixed-lane) track there is no concept of a manual lane; the
// item follows the normal auto-tag rule.
+8
View File
@@ -58,6 +58,14 @@ std::optional<std::string> managedLaneKey(const std::string& laneName);
// contract is asserted here so minting and reading cannot drift.
std::string laneNameForMode(const std::string& modeId);
// The owning mode id encoded in a managed lane NAME — the suffix after the managed
// prefix. std::nullopt for a manual/unnamed lane (no managed prefix) or a name that is
// EXACTLY the prefix with no mode suffix (illegal — a managed lane always names a mode).
// The exact inverse of laneNameForMode: modeIdFromLaneName(laneNameForMode(m)) == m.
// Used by the load-time reconcile to recover managed ownership from REAPER's durable
// lane name (the source of truth for identity across sessions — design point #2).
std::optional<std::string> modeIdFromLaneName(const std::string& laneName);
// True iff an item on a fixed-lane track with the given lane name is on a MANUAL lane
// (i.e. exempt from auto-tag). The two inputs are:
// isFixedLaneTrack — whether the item's track has I_FREEMODE==2. On a normal
+6
View File
@@ -224,6 +224,12 @@ static void OnTimer()
// re-arm and the model restore ride the one authoritative load event.
if (g_session.consumeLoadSignal()) {
reasampler::bankPanelNotifyProjectLoaded();
// Reconcile the restored lane-ownership index against the live project's lanes
// FIRST (via REAPER's durable P_LANENAME — the cross-session source of truth),
// so a saved lane-split project's managed/manual classification is correct
// before the active mode's lane visibility is reapplied. Never re-mints, never
// mass-tags — it only records managed ownership recovered from lane names.
reasampler::reconcileManagedLanes(g_session.view(), nullptr);
reasampler::applyMode(g_session.view(), g_session.view().activeModeId(), nullptr);
}
+261
View File
@@ -36,6 +36,13 @@
#define REAPERAPI_WANT_TrackList_AdjustWindows
#define REAPERAPI_WANT_UpdateArrange
#define REAPERAPI_WANT_UpdateTimeline
// Lane minting (D2 Wave 3): enumerate a track's items and read/write item-side lane
// state to assign each item to its mode's managed lane.
#define REAPERAPI_WANT_CountTrackMediaItems
#define REAPERAPI_WANT_GetTrackMediaItem
#define REAPERAPI_WANT_GetMediaItemInfo_Value
#define REAPERAPI_WANT_SetMediaItemInfo_Value
#define REAPERAPI_WANT_GetSetMediaItemInfo_String
#include "reaper_plugin_functions.h"
namespace reasampler {
@@ -236,6 +243,188 @@ bool applyLaneOps(const std::vector<std::pair<std::string, MediaTrack*>>& handle
return touchedFreeMode;
}
// -- Managed-lane minting (D2 Wave 3) ----------------------------------------
//
// Mints one managed fixed lane per mode on any track that now holds content of MORE
// THAN ONE mode, and assigns each item to its mode's managed lane. The DECISION —
// which tracks split, which lanes to mint, which item goes where — is the pure
// planLaneMinting; this shell only reads live per-item mode+lane state, calls the
// decision, and applies the resulting REAPER + ownership-index writes.
// An item's canonical GUID string via GetSetMediaItemInfo_String("GUID"). Empty on
// failure. Mirrors bank_panel.cpp's itemGuid — the same read seam for item identity.
std::string itemGuidString(MediaItem* it) {
char buf[64] = {0};
if (!GetSetMediaItemInfo_String(it, "GUID", buf, false)) return {};
return std::string(buf);
}
// The durable P_LANENAME of the lane item `it` currently sits on, for a track known to
// be a fixed-lane track. Empty if unnamed/unavailable. Same derivation as bank_panel's
// itemLaneName; kept local so view.cpp stays self-contained.
std::string itemLaneNameOf(MediaTrack* tr, MediaItem* it) {
const int laneIdx = static_cast<int>(GetMediaItemInfo_Value(it, "I_FIXEDLANE"));
return laneName(tr, laneIdx);
}
// Maps every item GUID on `tr` to its MediaItem* handle, in one pass. The assign pass
// resolves plan item GUIDs back to handles through this map rather than re-scanning the
// track per item (avoids the quadratic that a per-item find would incur).
std::map<std::string, MediaItem*> itemHandlesByGuid(MediaTrack* tr) {
std::map<std::string, MediaItem*> byGuid;
const int itemCount = CountTrackMediaItems(tr);
for (int i = 0; i < itemCount; ++i) {
MediaItem* it = GetTrackMediaItem(tr, i);
if (!it) continue;
std::string ig = itemGuidString(it);
if (!ig.empty()) byGuid.emplace(std::move(ig), it);
}
return byGuid;
}
// Resolves the mode one item's content belongs to, from the model's membership index.
// An item tagged into exactly one mode returns that mode; an untagged item is an
// Arrange member by default (mirrors leafBelongsToMode's untagged rule). A show-both or
// multi-mode item resolves to its first mode id — such items are unusual for lane
// content, and the pure decision only needs A mode per item; the managed-lane it lands
// on is that mode's lane. Never returns empty for a real item.
std::string itemModeFromMembership(const ViewModeModel& model, const std::string& itemGuid) {
const std::set<std::string> modes = model.membership().modesOf(itemGuid);
if (modes.empty()) return kArrangeModeId; // untagged ⇒ Arrange default
return *modes.begin();
}
// Builds the per-track LaneItem picture the pure decision consumes. For each track and
// each item: resolve the item's mode from membership, and — only on a track already in
// fixed-lane mode — read whether it sits on a MANUAL lane (exempt). On a non-fixed-lane
// track no item is on a manual lane (isOnManualLane returns false for the empty name),
// so the manual read is skipped entirely there.
std::vector<LaneTrack> readLaneTracks(
const ViewModeModel& model,
const std::vector<std::pair<std::string, MediaTrack*>>& handleByGuid) {
std::vector<LaneTrack> tracks;
tracks.reserve(handleByGuid.size());
for (const auto& [guid, tr] : handleByGuid) {
LaneTrack lt;
lt.trackGuid = guid;
const bool fixedLane =
static_cast<int>(GetMediaTrackInfo_Value(tr, "I_FREEMODE")) == kFreeModeFixedLanes;
const int itemCount = CountTrackMediaItems(tr);
lt.items.reserve(static_cast<std::size_t>(itemCount));
for (int i = 0; i < itemCount; ++i) {
MediaItem* it = GetTrackMediaItem(tr, i);
if (!it) continue;
const std::string ig = itemGuidString(it);
if (ig.empty()) continue;
LaneItem li;
li.guid = ig;
li.modeId = itemModeFromMembership(model, ig);
// Manual-lane exemption: only meaningful on a fixed-lane track. The shared
// pure predicate decides; on a normal track it returns false regardless of
// name, so we pass an empty name and skip the P_LANENAME read.
const std::string ln = fixedLane ? itemLaneNameOf(tr, it) : std::string{};
li.onManualLane = isOnManualLane(fixedLane, ln);
lt.items.push_back(std::move(li));
}
tracks.push_back(std::move(lt));
}
return tracks;
}
// Assigns item `it` to the managed lane whose durable key resolves to a current ordinal
// on `tr` (via managedLaneOrdinals). Idempotent: writes I_FIXEDLANE only when it differs
// from the item's current lane, so a re-run does not thrash the item or the undo state.
// Returns true iff a write actually changed the item's lane. Non-destructive: only the
// reversible I_FIXEDLANE flag is written — the item is never moved in time or across
// tracks. (I_FIXEDLANE is settable per SDK: "fine to call with setNewValue".)
bool assignItemToLane(MediaTrack* tr, MediaItem* it, int laneOrdinal) {
const int current = static_cast<int>(GetMediaItemInfo_Value(it, "I_FIXEDLANE"));
if (current == laneOrdinal) return false; // already there — no-op
SetMediaItemInfo_Value(it, "I_FIXEDLANE", static_cast<double>(laneOrdinal));
return true;
}
// Applies the pure LaneMintPlan to the live project. For each track that must split:
// enables fixed lanes, ensures the lane count, stamps each managed lane's durable name,
// records ownership in the model, then assigns each item to its mode's lane by resolving
// the durable key to the lane's current ordinal. Returns true if ANY project write
// changed state (⇒ the caller keeps the Undo block and refreshes the timeline).
//
// MANAGED-LANES-ONLY: the plan only ever names lanes with the managed prefix and only
// ever assigns managed-eligible items (manual-lane items were reported exempt and are
// absent from the plan). We only ever GROW I_NUMFIXEDLANES to fit the managed lanes and
// stamp names on the lanes we mint — a user's existing manual lanes keep their ordinals
// below/around ours and are never renamed or reassigned.
bool applyMintPlan(ViewModeModel& model, const LaneMintPlan& plan,
const std::vector<std::pair<std::string, MediaTrack*>>& handleByGuid) {
bool changed = false;
// Group mints + assigns by track so each track is set up once.
std::map<std::string, std::vector<const LaneMint*>> mintsByTrack;
for (const LaneMint& m : plan.mints) mintsByTrack[m.trackGuid].push_back(&m);
std::map<std::string, std::vector<const LaneAssign*>> assignsByTrack;
for (const LaneAssign& a : plan.assigns) assignsByTrack[a.trackGuid].push_back(&a);
for (const LaneMintPlan::TrackSplit& split : plan.splits) {
MediaTrack* tr = resolve(handleByGuid, split.trackGuid);
if (!tr) continue; // stale GUID — prune
// Enable fixed-lane mode if not already (SDK: UpdateTimeline() owed after).
const int freeMode = static_cast<int>(GetMediaTrackInfo_Value(tr, "I_FREEMODE"));
if (freeMode != kFreeModeFixedLanes) {
SetMediaTrackInfo_Value(tr, "I_FREEMODE", static_cast<double>(kFreeModeFixedLanes));
changed = true;
}
// Ensure enough lanes for the managed set WITHOUT shrinking: a track may already
// carry the user's manual lanes, so only GROW the count, never reduce it (which
// would delete a user lane). The managed lanes we mint occupy the tail ordinals.
const int haveLanes = static_cast<int>(GetMediaTrackInfo_Value(tr, "I_NUMFIXEDLANES"));
// Which managed keys are already present on this track (durable-name reconcile).
std::map<std::string, int> present = managedLaneOrdinals(tr);
// Mint each managed lane that is not already present, appending at the tail so an
// existing manual lane is never overwritten. Record ownership in the model.
int nextOrdinal = haveLanes;
for (const LaneMint* m : mintsByTrack[split.trackGuid]) {
model.lanes().setManaged(m->trackGuid, m->laneKey, m->modeId); // ownership
if (present.count(m->laneKey)) continue; // already minted — idempotent
// Grow the lane count to include the new tail ordinal, then stamp its name.
const int laneIdx = nextOrdinal++;
if (laneIdx >= static_cast<int>(GetMediaTrackInfo_Value(tr, "I_NUMFIXEDLANES"))) {
SetMediaTrackInfo_Value(tr, "I_NUMFIXEDLANES",
static_cast<double>(laneIdx + 1));
}
char parm[32];
std::snprintf(parm, sizeof(parm), "P_LANENAME:%d", laneIdx);
std::vector<char> name(m->laneKey.begin(), m->laneKey.end());
name.push_back('\0');
GetSetMediaTrackInfo_String(tr, parm, name.data(), true);
present.emplace(m->laneKey, laneIdx); // now resolvable for the assign pass
changed = true;
}
// Assign each item to its mode's managed lane, resolving the durable key to the
// lane's current ordinal on THIS track. A key not present (shouldn't happen — we
// just minted them all) is skipped rather than mis-assigned. Item handles are
// resolved through a one-pass GUID map (avoids re-scanning the track per item).
const std::map<std::string, int> ordinals = managedLaneOrdinals(tr);
const std::map<std::string, MediaItem*> itemsByGuid = itemHandlesByGuid(tr);
for (const LaneAssign* a : assignsByTrack[split.trackGuid]) {
auto ord = ordinals.find(a->laneKey);
if (ord == ordinals.end()) continue; // key not live — prune, never mis-assign
auto handle = itemsByGuid.find(a->itemGuid);
if (handle == itemsByGuid.end()) continue; // stale item GUID — prune
if (assignItemToLane(tr, handle->second, ord->second)) changed = true;
}
}
return changed;
}
} // namespace
bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject* proj) {
@@ -352,4 +541,76 @@ bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject
return true;
}
bool mintManagedLanes(ViewModeModel& model, ReaProject* proj) {
std::vector<std::pair<std::string, MediaTrack*>> handleByGuid;
readFolderEntries(proj, handleByGuid); // populates handleByGuid (tree unused here)
// Build the live per-track item picture and run the PURE decision. A single-mode
// track produces no split; a track that now holds >1 mode's content produces mints
// + assignments. Manual-lane items are reported exempt inside readLaneTracks.
const std::vector<LaneTrack> tracks = readLaneTracks(model, handleByGuid);
const LaneMintPlan plan = planLaneMinting(tracks);
if (plan.empty()) return false; // nothing to mint — no Undo point for a no-op tick
// Wrap the structural mutation in ONE Undo block (unlike the invisible membership
// tag). Only opened when the plan is non-empty; applyMintPlan reports whether any
// write actually changed state so we can label the undo meaningfully.
Undo_BeginBlock2(proj);
const bool changed = applyMintPlan(model, plan, handleByGuid);
if (!changed) {
// The plan was non-empty but every write was already satisfied (idempotent
// re-run: lanes exist, items already assigned, ownership already recorded). Close
// the block with no description so REAPER discards the empty undo point rather
// than flooding history with a no-change entry every detection tick.
Undo_EndBlock2(proj, "", 0);
return false;
}
// Reapply the active mode's lane visibility so the freshly-minted lanes take their
// correct play/show state immediately: the active mode's lane plays+shows, every
// other managed lane hides+silences. Reusing planToggle's lane ops keeps the drive
// logic in one place; applyLaneOps also (re)asserts I_FREEMODE and drives C_LANEPLAYS.
// NOTE: applyMode is NOT reused here — it would re-park/restore whole tracks and
// recompute parent visibility, which the minting tick must not do (it only just
// changed item lanes). Driving lane play state directly is the minimal correct step.
const TogglePlan togglePlan = model.planToggle(FolderTree{}, model.activeModeId());
applyLaneOps(handleByGuid, togglePlan.lanes);
// I_FREEMODE was (re)set to fixed lanes on at least one track (the plan minted a
// split), so a timeline refresh is owed (SDK). Repaint the arrange too so the new
// lane layout appears immediately.
UpdateTimeline();
UpdateArrange();
Undo_EndBlock2(proj, "ReaSampler: separate cross-mode content into lanes", -1);
return true;
}
void reconcileManagedLanes(ViewModeModel& model, ReaProject* proj) {
std::vector<std::pair<std::string, MediaTrack*>> handleByGuid;
readFolderEntries(proj, handleByGuid); // populates handleByGuid (tree unused here)
// Walk every track's lanes; for each lane whose durable name carries the managed
// prefix, record it MANAGED-for-its-mode in the ownership index. This is a pure READ
// of REAPER state (no lane is created, no I_FREEMODE/I_NUMFIXEDLANES/I_FIXEDLANE is
// written) plus an index write — self-healing classification from the source of
// truth (the durable name) without re-minting or mass-tagging. A lane lacking the
// prefix is left alone (manual by default), so a user's own lanes stay off the index.
for (const auto& [guid, tr] : handleByGuid) {
const int freeMode = static_cast<int>(GetMediaTrackInfo_Value(tr, "I_FREEMODE"));
if (freeMode != kFreeModeFixedLanes) continue; // no fixed lanes ⇒ nothing managed
const int numLanes = static_cast<int>(GetMediaTrackInfo_Value(tr, "I_NUMFIXEDLANES"));
for (int lane = 0; lane < numLanes; ++lane) {
const std::string name = laneName(tr, lane);
std::optional<std::string> key = managedLaneKey(name);
if (!key) continue; // manual/unnamed lane — leave off the index
std::optional<std::string> mode = modeIdFromLaneName(name);
if (!mode) continue; // prefix-only/illegal name — skip defensively
model.lanes().setManaged(guid, *key, *mode);
}
}
}
} // namespace reasampler
+37
View File
@@ -52,4 +52,41 @@ namespace reasampler {
// registered mode. `proj` may be nullptr to mean REAPER's current project.
bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject* proj);
// Mints managed fixed lanes for any track in `proj` that now holds content of MORE
// THAN ONE mode, and assigns each item to its mode's managed lane (Phase D2 Wave 3).
// 1. Enumerates every track + its items; resolves each item's mode from the model's
// membership (untagged ⇒ Arrange) and reads whether it currently sits on a MANUAL
// lane (exempt).
// 2. Runs the pure planLaneMinting decision. A track with content of only one mode
// is left whole-track-parked (D1) — NOT lane-split.
// 3. For each track that must split: enables fixed-lane mode (I_FREEMODE=2), ensures
// enough fixed lanes (I_NUMFIXEDLANES), stamps each managed lane's durable name
// (P_LANENAME:n), records the lane MANAGED-for-its-mode in the model's ownership
// index, and assigns each managed-eligible item to its mode's lane (I_FIXEDLANE).
// Manual lanes and the items on them are NEVER minted-over or reassigned.
// 4. Reapplies the active mode's lane visibility so the just-minted lanes take their
// correct play/show state immediately (the active mode's lane plays; others hide).
// The whole structural mutation is wrapped in ONE Undo_BeginBlock2/EndBlock2 — but only
// when the plan is non-empty (no undo point for a tick that mints nothing).
//
// Returns true if any lane was minted this call (⇒ the caller may want a repaint).
// `proj` may be nullptr to mean REAPER's current project. READ of the membership index
// only; the sole model mutation is recording new managed-lane ownership.
bool mintManagedLanes(ViewModeModel& model, ReaProject* proj);
// Reconciles the model's lane-ownership index against the live project's lanes on
// project open (Phase D2 Wave 3). REAPER's durable P_LANENAME is the source of truth for
// lane identity across sessions (design point #2): a lane whose name carries the managed
// prefix is tool-managed and owned by the mode encoded in that name. This walks every
// track's lanes and records each managed-named lane MANAGED-for-its-mode in the index —
// self-healing a saved project's classification WITHOUT re-minting (it never creates a
// lane, changes I_FREEMODE/I_NUMFIXEDLANES, or reassigns an item) and WITHOUT mass-
// tagging (it never touches membership). A lane without the managed prefix is left
// untouched (manual by default). Reload's active-mode lane visibility is then reapplied
// by the caller's applyMode, mirroring D1's reapply-on-open.
//
// `proj` may be nullptr to mean REAPER's current project. The only model mutation is
// recording managed ownership recovered from durable lane names.
void reconcileManagedLanes(ViewModeModel& model, ReaProject* proj);
} // namespace reasampler
+52
View File
@@ -9,6 +9,8 @@
#include <set>
#include <utility>
#include "lane_keys.h" // laneNameForMode — the ONE durable managed-lane-key convention
// view_mode_model implementation.
//
// JSON is hand-rolled and self-contained, mirroring bank_model's approach (brief:
@@ -156,6 +158,56 @@ std::vector<AutoTag> autoTagNewContent(const std::vector<std::string>& newTrackG
return tags;
}
// ---------------------------------------------------------------------------
// lane minting decision
// ---------------------------------------------------------------------------
LaneMintPlan planLaneMinting(const std::vector<LaneTrack>& tracks) {
LaneMintPlan plan;
for (const LaneTrack& track : tracks) {
if (track.trackGuid.empty()) continue;
// Collect the DISTINCT modes the track's managed-eligible items belong to, in
// deterministic (sorted) order so the mint list and lane count are stable across
// runs (a set orders by mode id). Items on a manual lane are EXEMPT — never
// counted toward the multi-mode test and never reassigned (the managed-only
// invariant, upheld at the source of the decision).
std::set<std::string> involvedModes;
for (const LaneItem& item : track.items) {
if (item.guid.empty() || item.modeId.empty()) continue;
if (item.onManualLane) continue; // exempt — user's hand-managed lane
involvedModes.insert(item.modeId);
}
// Single-mode (or empty) track: whole-track parking (D1) still separates the
// stances. NO split, NO mint, NO assignment — this is the load-bearing
// "don't lane-split single-mode tracks" rule.
if (involvedModes.size() < 2) continue;
// Multi-mode track: transition to lane-split. One managed lane per involved
// mode (durable key = laneNameForMode(mode)), owned by that mode.
plan.splits.push_back(LaneMintPlan::TrackSplit{
track.trackGuid, static_cast<int>(involvedModes.size())});
for (const std::string& mode : involvedModes) {
plan.mints.push_back(
LaneMint{track.trackGuid, laneNameForMode(mode), mode});
}
// Assign EVERY managed-eligible item onto its mode's lane — including the
// pre-existing single-mode items, so a track that just gained a second mode
// retroactively lanes all of its content, not only the newly-added item.
for (const LaneItem& item : track.items) {
if (item.guid.empty() || item.modeId.empty()) continue;
if (item.onManualLane) continue; // exempt — never reassigned
plan.assigns.push_back(LaneAssign{
item.guid, track.trackGuid, laneNameForMode(item.modeId)});
}
}
return plan;
}
// ---------------------------------------------------------------------------
// planner helpers
// ---------------------------------------------------------------------------
+107
View File
@@ -519,6 +519,113 @@ std::vector<AutoTag> autoTagNewContent(const std::vector<std::string>& newTrackG
const std::vector<NewItem>& newItems,
const std::string& activeMode);
// -- Lane minting decision (Phase D2 / Wave 3) -------------------------------
//
// D1 parks a whole track when it holds content of only ONE mode. The moment a track
// would carry content of MORE THAN ONE mode, whole-track parking can no longer keep
// the stances separate (the track is visible in every mode its content belongs to),
// so the projection drops to the ITEM level: the track becomes a fixed-lane track,
// each involved mode gets its own MANAGED lane, and each item is assigned to its
// mode's lane. A toggle then shows+plays only the active mode's lane.
//
// This is the pure DECISION behind that transition — REAPER-free and unit-tested.
// The shell reads each track's items and their live mode+lane disposition, calls this,
// and applies the resulting REAPER writes (I_FREEMODE / I_NUMFIXEDLANES / P_LANENAME /
// I_FIXEDLANE) plus the ownership-index writes. The DECISION never lives in the shell.
//
// THE MANAGED-LANES-ONLY INVARIANT is upheld here at the source: an item the shell
// reports as already on a MANUAL lane is EXEMPT — it is never counted toward the
// multi-mode test, never reassigned, and its lane is never minted-over. The plan only
// ever names lanes with the managed prefix (laneNameForMode) and only ever moves
// managed-eligible items. A track the user already lane-splits for their own comping
// is handled by minting ADDITIONAL managed lanes alongside the user's manual lanes;
// the manual lanes and the items on them are untouched (they are reported exempt).
// One item the shell reports for the minting decision: its GUID, the mode its
// membership resolves to (untagged ⇒ Arrange, resolved by the shell via
// leafBelongsToMode / the active-mode default), and whether it currently sits on a
// MANUAL lane (⇒ exempt: never counted, never reassigned).
struct LaneItem {
std::string guid;
std::string modeId; // the mode this item's content belongs to
bool onManualLane = false; // true ⇒ EXEMPT (user's hand-managed lane)
};
// One track the shell reports: its GUID plus the items on it. The shell builds this by
// enumerating the track's media items and resolving each item's mode from membership.
struct LaneTrack {
std::string trackGuid;
std::vector<LaneItem> items;
};
// One item→lane assignment the shell must apply (I_FIXEDLANE = the lane the durable
// key `laneKey` currently occupies; the shell resolves key→ordinal exactly as the
// C_LANEPLAYS apply path does). Only managed-eligible items appear here.
struct LaneAssign {
std::string itemGuid;
std::string trackGuid;
std::string laneKey; // durable managed-lane key (laneNameForMode(modeId))
bool operator==(const LaneAssign& o) const {
return itemGuid == o.itemGuid && trackGuid == o.trackGuid && laneKey == o.laneKey;
}
};
// One managed lane the shell must mint on a track: its durable key (== the name to
// stamp via P_LANENAME) and the mode that owns it (recorded in the ownership index).
struct LaneMint {
std::string trackGuid;
std::string laneKey; // == laneNameForMode(modeId); the P_LANENAME to stamp
std::string modeId; // the owning mode (ownership-index managed-for-mode write)
bool operator==(const LaneMint& o) const {
return trackGuid == o.trackGuid && laneKey == o.laneKey && modeId == o.modeId;
}
};
// The complete lane-minting plan for the tracks the shell reported. Empty (all three
// vectors) when NO track needs splitting — a single-mode-only project produces an empty
// plan and the shell does nothing (D1 behavior unchanged). The shell wraps the whole
// application in ONE Undo block because it is a visible structural mutation.
struct LaneMintPlan {
// Tracks to switch into fixed-lane mode, each with the number of managed lanes to
// ensure (I_FREEMODE=2, I_NUMFIXEDLANES >= laneCount). Only tracks that need a
// split appear; a track already carrying the tool's managed lanes for exactly the
// involved modes still appears (idempotent — the shell's ensure is a no-op then).
struct TrackSplit {
std::string trackGuid;
int laneCount = 0; // number of managed lanes this track needs
};
std::vector<TrackSplit> splits;
std::vector<LaneMint> mints; // managed lanes to mint (name + ownership write)
std::vector<LaneAssign> assigns; // item→managed-lane assignments
bool empty() const {
return splits.empty() && mints.empty() && assigns.empty();
}
};
// The pure lane-minting decision. For each reported track:
// * Ignore items on manual lanes entirely (exempt — the managed-only invariant).
// * Collect the DISTINCT modes the remaining (managed-eligible) items belong to.
// * If that set has < 2 modes, the track stays whole-track-parked (D1) — NO split,
// NO mint, NO assignment. This is the single-mode-track rule.
// * If it has >= 2 modes, the track transitions to lane-split: emit one TrackSplit
// (laneCount == number of involved modes), one LaneMint per involved mode (durable
// key laneNameForMode(mode), owned by that mode), and one LaneAssign per managed-
// eligible item onto its mode's lane — INCLUDING the pre-existing items, so a
// single-mode track that just gained a second mode retroactively lanes ALL its
// items, not only the newly-added one.
//
// Items with an empty GUID or empty modeId are skipped (defensive; a real item always
// resolves to a mode). The function mutates nothing — it returns a plan the shell
// applies. Idempotency: re-reporting an already-split track yields the same mints and
// assignments; the shell's ensure/assign writes are no-ops when the state already
// matches, so re-running the detection path does not thrash the project or the undo
// history (the shell only opens an Undo block when the plan is non-empty AND some
// write actually changes state — see the shell).
LaneMintPlan planLaneMinting(const std::vector<LaneTrack>& tracks);
// The next mode id in the registry's ordinal order, cycling past `currentModeId`
// and wrapping to the first mode after the last (Arrange -> Design -> Arrange with
// the two seed modes; the same cycle scales to N modes with no call-site change).
+20
View File
@@ -82,11 +82,31 @@ static void testRoundTrip() {
}
}
static void testModeIdFromLaneName() {
// The exact inverse of laneNameForMode: recover the owning mode from a managed name.
// Used by the Wave-3 load-time reconcile to rebuild ownership from durable names.
for (const std::string mode : {std::string("arrange"), std::string("design"),
std::string("mixdown"), std::string("mode:with:colons")}) {
auto recovered = modeIdFromLaneName(laneNameForMode(mode));
CHECK(recovered.has_value() && *recovered == mode); // modeIdFromLaneName∘laneNameForMode == id
}
// Manual / unnamed lanes carry no mode (⇒ left off the ownership index on reconcile).
CHECK(!modeIdFromLaneName("").has_value());
CHECK(!modeIdFromLaneName("Comp 1").has_value());
CHECK(!modeIdFromLaneName("Reasampler:design").has_value()); // wrong case ⇒ manual
// Prefix-only with no mode suffix is illegal for a managed lane ⇒ no mode recovered
// (defensive: reconcile skips it rather than recording an empty-mode ownership).
CHECK(!modeIdFromLaneName("reasampler:").has_value());
}
int main() {
testIsManagedLaneName();
testManagedLaneKey();
testIsOnManualLane();
testRoundTrip();
testModeIdFromLaneName();
if (g_fail == 0) std::printf("All tests passed.\n");
return g_fail ? 1 : 0;
+152
View File
@@ -16,6 +16,7 @@
// guards the in-DAW "all leaves hidden after toggling twice" regression.
#include "../src/view_mode_model.h"
#include "../src/lane_keys.h" // laneNameForMode — assert the minting plan's durable keys
#include <algorithm>
#include <cstdio>
@@ -1073,6 +1074,153 @@ static void testAutoTagDecision() {
}
}
// -- D2.7 Lane minting decision (Wave 3) -------------------------------------
//
// planLaneMinting: a track with content of only ONE mode is NOT split (D1 unchanged);
// a track that holds >1 mode's content mints one managed lane per mode and assigns EVERY
// managed-eligible item (incl. pre-existing) to its mode's lane; manual-lane items are
// exempt (never counted, never reassigned, their lane never minted-over).
static bool hasMint(const LaneMintPlan& p, const std::string& track,
const std::string& mode) {
for (const auto& m : p.mints)
if (m.trackGuid == track && m.modeId == mode &&
m.laneKey == laneNameForMode(mode))
return true;
return false;
}
static bool hasAssign(const LaneMintPlan& p, const std::string& item,
const std::string& track, const std::string& mode) {
for (const auto& a : p.assigns)
if (a.itemGuid == item && a.trackGuid == track &&
a.laneKey == laneNameForMode(mode))
return true;
return false;
}
static int splitLaneCount(const LaneMintPlan& p, const std::string& track) {
for (const auto& s : p.splits)
if (s.trackGuid == track) return s.laneCount;
return -1; // no split for this track
}
static void testLaneMintingSingleModeNoSplit() {
// A track whose items all belong to ONE mode is NOT lane-split — D1 whole-track
// parking still separates the stances. No split, no mint, no assignment.
std::vector<LaneTrack> tracks{
LaneTrack{"{T}", {
LaneItem{"{i1}", kArrangeModeId, false},
LaneItem{"{i2}", kArrangeModeId, false},
}},
};
const LaneMintPlan plan = planLaneMinting(tracks);
CHECK(plan.empty());
CHECK(splitLaneCount(plan, "{T}") == -1);
// An empty track (no items) is likewise never split.
CHECK(planLaneMinting({LaneTrack{"{E}", {}}}).empty());
}
static void testLaneMintingMultiModeMintsAndAssignsAll() {
// A track that gained a second mode's item: it now holds Arrange + Design content.
// Both modes get a managed lane; ALL managed-eligible items are assigned — including
// the pre-existing Arrange item (retroactive lane assignment), not only the new one.
std::vector<LaneTrack> tracks{
LaneTrack{"{T}", {
LaneItem{"{arr1}", kArrangeModeId, false}, // pre-existing single-mode item
LaneItem{"{arr2}", kArrangeModeId, false}, // pre-existing single-mode item
LaneItem{"{des1}", kDesignModeId, false}, // the newly-added 2nd-mode item
}},
};
const LaneMintPlan plan = planLaneMinting(tracks);
CHECK(!plan.empty());
// One split with two managed lanes (one per involved mode).
CHECK(splitLaneCount(plan, "{T}") == 2);
CHECK(plan.mints.size() == 2);
CHECK(hasMint(plan, "{T}", kArrangeModeId));
CHECK(hasMint(plan, "{T}", kDesignModeId));
// EVERY managed-eligible item assigned to its mode's lane — pre-existing included.
CHECK(plan.assigns.size() == 3);
CHECK(hasAssign(plan, "{arr1}", "{T}", kArrangeModeId)); // retroactive
CHECK(hasAssign(plan, "{arr2}", "{T}", kArrangeModeId)); // retroactive
CHECK(hasAssign(plan, "{des1}", "{T}", kDesignModeId)); // the new item
}
static void testLaneMintingManualLaneExempt() {
// A track with Arrange + Design managed-eligible content AND an item the user placed
// on a manual lane: the manual item is EXEMPT — it is not counted, not assigned, and
// its lane is never minted-over. The managed split proceeds around it.
std::vector<LaneTrack> tracks{
LaneTrack{"{T}", {
LaneItem{"{arr}", kArrangeModeId, false},
LaneItem{"{des}", kDesignModeId, false},
LaneItem{"{comp}", kDesignModeId, /*onManualLane=*/true}, // user's comp take
}},
};
const LaneMintPlan plan = planLaneMinting(tracks);
// Split for the two managed modes; the manual item never appears in assigns.
CHECK(splitLaneCount(plan, "{T}") == 2);
CHECK(plan.assigns.size() == 2);
CHECK(hasAssign(plan, "{arr}", "{T}", kArrangeModeId));
CHECK(hasAssign(plan, "{des}", "{T}", kDesignModeId));
for (const auto& a : plan.assigns)
CHECK(a.itemGuid != "{comp}"); // manual-lane item NEVER reassigned
// Manual-lane exemption can also SUPPRESS a split: if the ONLY second mode is
// supplied by a manual-lane item, the managed-eligible items are single-mode ⇒ NO
// split (the user's manual lane is not a mode the tool separates).
std::vector<LaneTrack> t2{
LaneTrack{"{U}", {
LaneItem{"{a}", kArrangeModeId, false},
LaneItem{"{d}", kDesignModeId, /*onManualLane=*/true}, // only 2nd mode, exempt
}},
};
CHECK(planLaneMinting(t2).empty()); // managed-eligible content is single-mode ⇒ no split
}
static void testLaneMintingThreeModesAndOwnershipKeys() {
// N-mode proof + the ownership writes the shell will apply: three modes on one track
// mint three managed lanes, each keyed by its durable name (== laneNameForMode), each
// owning the right mode. Applying the mints to a real ownership index reproduces the
// managed classification the toggle planner then gates on.
ViewModeModel vm;
CHECK(vm.modes().add(Mode{"mixdown", "Mixdown", 2}));
std::vector<LaneTrack> tracks{
LaneTrack{"{T}", {
LaneItem{"{a}", kArrangeModeId, false},
LaneItem{"{d}", kDesignModeId, false},
LaneItem{"{m}", "mixdown", false},
}},
};
const LaneMintPlan plan = planLaneMinting(tracks);
CHECK(splitLaneCount(plan, "{T}") == 3);
CHECK(plan.mints.size() == 3);
// Apply the mints exactly as the shell does — record managed ownership — then assert
// the ownership index classifies each lane managed-for-its-mode and the toggle
// planner would drive exactly these three lanes (managed-only invariant intact).
for (const auto& m : plan.mints)
CHECK(vm.lanes().setManaged(m.trackGuid, m.laneKey, m.modeId));
CHECK(vm.lanes().size() == 3);
CHECK(vm.lanes().isManaged("{T}", laneNameForMode(kArrangeModeId)));
CHECK(vm.lanes().isManaged("{T}", laneNameForMode(kDesignModeId)));
CHECK(vm.lanes().isManaged("{T}", laneNameForMode("mixdown")));
CHECK(vm.lanesTouchedByToggle().size() == 3);
// Persist round-trip of the just-minted lane-split project: the ownership index (and
// the whole model) survives serialize/deserialize unchanged, so a saved lane-split
// project restores its managed classification without re-minting.
auto back = ViewModeModel::deserialize(vm.serialize());
CHECK(back.has_value());
CHECK(back && *back == vm);
if (back) CHECK(back->lanes().size() == 3);
}
// -- D2.6 JSON round-trip with lane index + membership -----------------------
static void testLaneJsonRoundTrip() {
@@ -1161,6 +1309,10 @@ int main() {
testLaneOwnershipLastWriterWins();
testManagedOnlyPlannerAndQuery();
testAutoTagDecision();
testLaneMintingSingleModeNoSplit();
testLaneMintingMultiModeMintsAndAssignsAll();
testLaneMintingManualLaneExempt();
testLaneMintingThreeModesAndOwnershipKeys();
testLaneJsonRoundTrip();
testLaneMalformedJson();