From b182146f9a6e28624db708eb0a10b6417cb2d787 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Thu, 23 Jul 2026 20:08:38 -0400 Subject: [PATCH] feat(view): mint managed lanes to separate cross-mode content per stance (D2 W3-A) Pure planLaneMinting decides which tracks hold >1 mode's content and which managed lane each item lands on; view.cpp applies it (I_FREEMODE/I_NUMFIXEDLANES/P_LANENAME/ I_FIXEDLANE) under one undo block, driven off the auto-tag detection tick. Manual lanes and their items are never touched. Load-time reconcile rebuilds ownership from durable lane names before reapplying active-mode visibility. --- CMakeLists.txt | 4 + src/bank_panel.cpp | 23 ++- src/lane_keys.cpp | 7 + src/lane_keys.h | 8 + src/main.cpp | 6 + src/view.cpp | 261 +++++++++++++++++++++++++++++++++ src/view.h | 37 +++++ src/view_mode_model.cpp | 52 +++++++ src/view_mode_model.h | 107 ++++++++++++++ tests/test_lane_keys.cpp | 20 +++ tests/test_view_mode_model.cpp | 152 +++++++++++++++++++ 11 files changed, 673 insertions(+), 4 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 8284730..2b5fec5 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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 diff --git a/src/bank_panel.cpp b/src/bank_panel.cpp index 589977e..f29c26e 100644 --- a/src/bank_panel.cpp +++ b/src/bank_panel.cpp @@ -753,8 +753,11 @@ void enumerateLiveGuids(ReaProject* proj, std::set& 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 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 diff --git a/src/lane_keys.cpp b/src/lane_keys.cpp index 83b4c7a..41dcf41 100644 --- a/src/lane_keys.cpp +++ b/src/lane_keys.cpp @@ -31,6 +31,13 @@ std::string laneNameForMode(const std::string& modeId) { return std::string(kManagedLanePrefix) + modeId; } +std::optional 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. diff --git a/src/lane_keys.h b/src/lane_keys.h index 29ffee1..a2a6446 100644 --- a/src/lane_keys.h +++ b/src/lane_keys.h @@ -58,6 +58,14 @@ std::optional 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 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 diff --git a/src/main.cpp b/src/main.cpp index f19d99c..281da5d 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -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); } diff --git a/src/view.cpp b/src/view.cpp index 73d90aa..febaf06 100644 --- a/src/view.cpp +++ b/src/view.cpp @@ -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>& 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(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 itemHandlesByGuid(MediaTrack* tr) { + std::map 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 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 readLaneTracks( + const ViewModeModel& model, + const std::vector>& handleByGuid) { + std::vector tracks; + tracks.reserve(handleByGuid.size()); + for (const auto& [guid, tr] : handleByGuid) { + LaneTrack lt; + lt.trackGuid = guid; + + const bool fixedLane = + static_cast(GetMediaTrackInfo_Value(tr, "I_FREEMODE")) == kFreeModeFixedLanes; + + const int itemCount = CountTrackMediaItems(tr); + lt.items.reserve(static_cast(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(GetMediaItemInfo_Value(it, "I_FIXEDLANE")); + if (current == laneOrdinal) return false; // already there — no-op + SetMediaItemInfo_Value(it, "I_FIXEDLANE", static_cast(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>& handleByGuid) { + bool changed = false; + + // Group mints + assigns by track so each track is set up once. + std::map> mintsByTrack; + for (const LaneMint& m : plan.mints) mintsByTrack[m.trackGuid].push_back(&m); + std::map> 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(GetMediaTrackInfo_Value(tr, "I_FREEMODE")); + if (freeMode != kFreeModeFixedLanes) { + SetMediaTrackInfo_Value(tr, "I_FREEMODE", static_cast(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(GetMediaTrackInfo_Value(tr, "I_NUMFIXEDLANES")); + + // Which managed keys are already present on this track (durable-name reconcile). + std::map 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(GetMediaTrackInfo_Value(tr, "I_NUMFIXEDLANES"))) { + SetMediaTrackInfo_Value(tr, "I_NUMFIXEDLANES", + static_cast(laneIdx + 1)); + } + char parm[32]; + std::snprintf(parm, sizeof(parm), "P_LANENAME:%d", laneIdx); + std::vector 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 ordinals = managedLaneOrdinals(tr); + const std::map 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> 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 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> 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(GetMediaTrackInfo_Value(tr, "I_FREEMODE")); + if (freeMode != kFreeModeFixedLanes) continue; // no fixed lanes ⇒ nothing managed + + const int numLanes = static_cast(GetMediaTrackInfo_Value(tr, "I_NUMFIXEDLANES")); + for (int lane = 0; lane < numLanes; ++lane) { + const std::string name = laneName(tr, lane); + std::optional key = managedLaneKey(name); + if (!key) continue; // manual/unnamed lane — leave off the index + std::optional mode = modeIdFromLaneName(name); + if (!mode) continue; // prefix-only/illegal name — skip defensively + model.lanes().setManaged(guid, *key, *mode); + } + } +} + } // namespace reasampler diff --git a/src/view.h b/src/view.h index 46b7fa1..3c519db 100644 --- a/src/view.h +++ b/src/view.h @@ -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 diff --git a/src/view_mode_model.cpp b/src/view_mode_model.cpp index 409c0a5..4b8dac8 100644 --- a/src/view_mode_model.cpp +++ b/src/view_mode_model.cpp @@ -9,6 +9,8 @@ #include #include +#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 autoTagNewContent(const std::vector& newTrackG return tags; } +// --------------------------------------------------------------------------- +// lane minting decision +// --------------------------------------------------------------------------- + +LaneMintPlan planLaneMinting(const std::vector& 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 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(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 // --------------------------------------------------------------------------- diff --git a/src/view_mode_model.h b/src/view_mode_model.h index 9a843cf..01c2c78 100644 --- a/src/view_mode_model.h +++ b/src/view_mode_model.h @@ -519,6 +519,113 @@ std::vector autoTagNewContent(const std::vector& newTrackG const std::vector& 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 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 splits; + std::vector mints; // managed lanes to mint (name + ownership write) + std::vector 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& 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). diff --git a/tests/test_lane_keys.cpp b/tests/test_lane_keys.cpp index 5f33e6f..82a280b 100644 --- a/tests/test_lane_keys.cpp +++ b/tests/test_lane_keys.cpp @@ -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; diff --git a/tests/test_view_mode_model.cpp b/tests/test_view_mode_model.cpp index 7443962..f994fb0 100644 --- a/tests/test_view_mode_model.cpp +++ b/tests/test_view_mode_model.cpp @@ -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 #include @@ -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 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 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 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 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 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();