diff --git a/CMakeLists.txt b/CMakeLists.txt index 6b2a298..b2e779e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -75,6 +75,28 @@ add_library(view_tree STATIC src/view_tree.cpp) target_include_directories(view_tree PUBLIC src) target_link_libraries(view_tree PUBLIC view_mode_model) +# --------------------------------------------------------------------------- +# 2d'') Pure guid_diff library — NO REAPER, NO SWELL. The D2 Wave-2 new-content +# detection core: current \ previous GUID diff + the first-poll-after-open +# baseline guard (and per-project reset). Split out so the fiddly baseline/diff +# logic is unit-tested outside the DAW; the bank_panel timer that reads REAPER's +# live track/item GUID set and applies the tags is DAW-verified. Mirror of +# view_tree splitting the folder-depth walk out of view.cpp. +# --------------------------------------------------------------------------- +add_library(guid_diff STATIC src/guid_diff.cpp) +target_include_directories(guid_diff PUBLIC src) + +# --------------------------------------------------------------------------- +# 2d''') Pure lane_keys library — NO REAPER, NO SWELL. The managed/manual fixed-lane +# heuristic (D2 Wave-2): a lane whose durable P_LANENAME:n carries the +# "reasampler:" prefix is tool-managed and keyed by that stable name; any other +# lane is user-minted manual and off-limits. Resolves design point #1 (auto-tag +# exemption) and #2 (name-keyed identity survives ordinal renumber). Split out +# so the prefix rule is unit-tested; view.cpp reads the names from REAPER. +# --------------------------------------------------------------------------- +add_library(lane_keys STATIC src/lane_keys.cpp) +target_include_directories(lane_keys PUBLIC src) + # --------------------------------------------------------------------------- # 2e) Pure insert_plan library — NO REAPER, NO SWELL. The InsertMedia `mode` # bitmask arithmetic behind the `insert` shell (M6). Split out so the @@ -140,6 +162,14 @@ add_executable(view_tree_tests tests/test_view_tree.cpp) target_link_libraries(view_tree_tests PRIVATE view_tree) add_test(NAME view_tree_tests COMMAND view_tree_tests) +add_executable(guid_diff_tests tests/test_guid_diff.cpp) +target_link_libraries(guid_diff_tests PRIVATE guid_diff) +add_test(NAME guid_diff_tests COMMAND guid_diff_tests) + +add_executable(lane_keys_tests tests/test_lane_keys.cpp) +target_link_libraries(lane_keys_tests PRIVATE lane_keys) +add_test(NAME lane_keys_tests COMMAND lane_keys_tests) + add_executable(insert_plan_tests tests/test_insert_plan.cpp) target_link_libraries(insert_plan_tests PRIVATE insert_plan) add_test(NAME insert_plan_tests COMMAND insert_plan_tests) @@ -181,6 +211,8 @@ add_library(reaper_reasampler MODULE src/view_tree.cpp src/view.cpp src/track_guid.cpp + src/guid_diff.cpp + src/lane_keys.cpp src/actions.cpp ) target_link_libraries(reaper_reasampler PRIVATE bank_model capture_paths peaks bank_grid mode_switch view_mode_model insert_plan render_settings realtime_record) diff --git a/src/bank_panel.cpp b/src/bank_panel.cpp index 5e09aa9..29f80c6 100644 --- a/src/bank_panel.cpp +++ b/src/bank_panel.cpp @@ -32,6 +32,8 @@ #include #include +#include +#include #include #include #include @@ -39,10 +41,14 @@ #include "bank_grid.h" #include "bank_model.h" #include "capture_paths.h" +#include "guid_diff.h" // GuidBaseline — new-content detection (D2 Wave 2) +#include "lane_keys.h" // managed/manual lane heuristic (D2 Wave 2) #include "mode_switch.h" #include "peaks.h" #include "persist.h" +#include "track_guid.h" // guidString — canonical track GUID key (D2 Wave 2) #include "view.h" // applyMode — the D2/D4 mode-activation entrypoint the switch fires +#include "view_mode_model.h" // autoTagNewContent / NewItem (D2 Wave 2) // SWELL / LICE. On macOS/Linux SWELL is provided by the host (SWELL_PROVIDED_BY_APP); // on Windows we use native Win32 (windows.h first, then swell.h no-ops on _WIN32). @@ -73,6 +79,16 @@ #define REAPERAPI_WANT_GetMainHwnd #define REAPERAPI_WANT_PCM_Source_CreateFromFile #define REAPERAPI_WANT_PCM_Source_Destroy +// New-content detection (D2 Wave 2): enumerate live tracks + items and read fixed-lane +// state to classify an item's lane as managed vs manual. +#define REAPERAPI_WANT_CountTracks +#define REAPERAPI_WANT_GetTrack +#define REAPERAPI_WANT_GetMediaTrackInfo_Value +#define REAPERAPI_WANT_GetSetMediaTrackInfo_String +#define REAPERAPI_WANT_CountTrackMediaItems +#define REAPERAPI_WANT_GetTrackMediaItem +#define REAPERAPI_WANT_GetMediaItemInfo_Value +#define REAPERAPI_WANT_GetSetMediaItemInfo_String // Stock preview API (verified against reaper_plugin.h / reaper_plugin_functions.h): // PlayPreview/StopPreview drive a caller-owned preview_register_t. These are the // STOCK symbols (not SWS-only) — see the audition section below. @@ -185,6 +201,20 @@ struct PanelState { PCM_source* previewSrc = nullptr; bool previewActive = false; bool previewInited = false; // guards double init / deinit + + // --- New-content detection (D2 Wave 2) ------------------------------------ + // + // Each timer tick diffs the live track+item GUID set against the previous tick to + // auto-tag content created SINCE the last tick into the then-active mode. The + // baseline carries the first-poll-after-open guard so pre-existing content is never + // mass-tagged (it stays Arrange). `lastProject` detects a project switch so the + // baseline re-arms per project (a switch never diffs across two projects). Both live + // for the extension's lifetime alongside the session, independent of panel open/close + // — detection must run whether or not the dock is visible (content is created in the + // arrange, not the panel). + GuidBaseline contentBaseline; + ReaProject* lastProject = nullptr; + bool sawProject = false; // false until the first detect tick sees a project }; PanelState g_panel; @@ -526,6 +556,112 @@ bool refreshFingerprint() { return true; } +// --- New-content detection (D2 Wave 2) ---------------------------------------- +// +// REAPER exposes no "item/track added" callback, so we diff live project state on the +// existing timer. Each tick: enumerate every track GUID and every item GUID, diff +// against the previous tick (GuidBaseline, first-poll-guarded), and auto-tag the new +// GUIDs into the active mode via the pure autoTagNewContent. An item on a MANUAL lane +// is exempt (design point #1) — its lane's durable name lacks the managed prefix. All +// enumeration is READ-ONLY on the project; the only mutation is to the in-memory +// membership index (persisted by persist on the next save, same as an action-driven tag). + +// The durable name of item `it`'s fixed lane, or empty if the item's track is not in +// fixed-lane mode (⇒ not a lane at all, treated as non-manual normal content). Mirrors +// view.cpp's laneName but item-side: read the item's I_FIXEDLANE ordinal, then that +// lane's P_LANENAME:n off the owning track. +std::string itemLaneName(MediaTrack* tr, MediaItem* it) { + if (static_cast(GetMediaTrackInfo_Value(tr, "I_FREEMODE")) != 2) + return {}; // not a fixed-lane track ⇒ no lane name (normal timeline content) + const int laneIdx = static_cast(GetMediaItemInfo_Value(it, "I_FIXEDLANE")); + char parm[32]; + std::snprintf(parm, sizeof(parm), "P_LANENAME:%d", laneIdx); + char buf[512] = {0}; + if (!GetSetMediaTrackInfo_String(tr, parm, buf, false)) return {}; + return std::string(buf); +} + +// An item's canonical GUID string via GetSetMediaItemInfo_String("GUID"). Empty on +// failure (a read failure must never be tagged — guid_diff/autoTag both skip empties). +std::string itemGuid(MediaItem* it) { + char buf[64] = {0}; + if (!GetSetMediaItemInfo_String(it, "GUID", buf, false)) return {}; + return std::string(buf); +} + +// Enumerates the live project's track + item GUIDs. Fills `allGuids` (the full live set, +// baseline input) and, for each item, records whether it sits on a manual lane so a +// newly-detected item can be exempted from auto-tag without a second project walk. +void enumerateLiveGuids(ReaProject* proj, std::set& allGuids, + std::map& itemOnManualLane) { + const int trackCount = CountTracks(proj); + for (int t = 0; t < trackCount; ++t) { + MediaTrack* tr = GetTrack(proj, t); + if (!tr) continue; + std::string tg = guidString(tr); + if (!tg.empty()) allGuids.insert(tg); + + const int itemCount = CountTrackMediaItems(tr); + for (int i = 0; i < itemCount; ++i) { + MediaItem* it = GetTrackMediaItem(tr, i); + if (!it) continue; + std::string ig = itemGuid(it); + if (ig.empty()) continue; + allGuids.insert(ig); + // Manual iff the item is on a fixed lane whose name is NOT tool-managed. + const std::string ln = itemLaneName(tr, it); + itemOnManualLane[ig] = (!ln.empty() && !isManagedLaneName(ln)); + } + } +} + +// One detection tick: diff live GUIDs against the baseline and auto-tag the new ones +// into the active mode. Runs every timer tick regardless of panel open/close (content +// is created in the arrange). READ-ONLY on the project; mutates only the in-memory +// membership index. +void detectNewContent() { + if (!g_panel.session) return; + + ReaProject* proj = EnumProjects(-1, nullptr, 0); + + // Project switch (or first ever tick) re-arms the first-poll guard so we never diff + // across two projects. GUID-address recycling is bounded here: a missed reset can at + // worst re-baseline against the wrong project for one tick; the identity-of-record + // (persist's minted project GUID) governs the bank/model reload, not this detector. + if (!g_panel.sawProject || proj != g_panel.lastProject) { + g_panel.contentBaseline.reset(); + g_panel.lastProject = proj; + g_panel.sawProject = true; + } + + std::set live; + std::map itemOnManualLane; + 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 + + // 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 + // item; otherwise it is a track (track GUIDs never appear in that map). + std::vector newTracks; + std::vector newItems; + for (const std::string& g : added) { + auto it = itemOnManualLane.find(g); + if (it == itemOnManualLane.end()) { + newTracks.push_back(g); // a track GUID + } else { + newItems.push_back(NewItem{g, it->second}); // an item; carries its exemption + } + } + + ViewModeModel& model = g_panel.session->view(); + const std::vector tags = + autoTagNewContent(newTracks, newItems, model.activeModeId()); + for (const AutoTag& tag : tags) + model.membership().tag(tag.guid, tag.modeId); +} + // --- Audition preview --------------------------------------------------------- // // READ-ONLY / NON-DESTRUCTIVE (load-bearing principle): audition is PREVIEW @@ -899,6 +1035,12 @@ std::vector bankPanelSelectedSampleIds() { } void bankPanelRefresh() { + // New-content auto-tag detection runs EVERY tick regardless of panel open/close: + // 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(); + if (!g_panel.open || !g_panel.hwnd) return; // Repaint only when the bank actually changed (generation bump). Cheap tick // otherwise — just a fingerprint string compare. diff --git a/src/guid_diff.cpp b/src/guid_diff.cpp new file mode 100644 index 0000000..9a8253c --- /dev/null +++ b/src/guid_diff.cpp @@ -0,0 +1,44 @@ +// guid_diff implementation — pure set arithmetic for new-content detection. See +// guid_diff.h. No REAPER, no SWELL — std only. + +#include "guid_diff.h" + +#include + +namespace reasampler { + +std::vector newGuids(const std::set& previous, + const std::set& current) { + std::vector added; + // current \ previous. std::set iterates ascending, so set_difference yields a + // deterministic order without a separate sort. + for (const std::string& g : current) { + if (g.empty()) continue; // never tag a GUID-read failure + if (previous.count(g) == 0) added.push_back(g); + } + return added; +} + +std::vector GuidBaseline::observe(const std::set& current) { + if (!primed_) { + // First poll after open/reset: establish the baseline, report nothing new so + // pre-existing content is NOT auto-tagged (it defaults to Arrange). + baseline_ = current; + primed_ = true; + return {}; + } + std::vector added = newGuids(baseline_, current); + // Advance the baseline to the full current set. Using `current` (not baseline_ ∪ + // added) means a DELETED GUID drops out of the baseline too, so if REAPER later + // reuses that GUID for genuinely new content it is detected again — the baseline + // tracks the live set exactly, not a monotonic union. + baseline_ = current; + return added; +} + +void GuidBaseline::reset() { + baseline_.clear(); + primed_ = false; // next observe() re-baselines (first-poll guard re-armed) +} + +} // namespace reasampler diff --git a/src/guid_diff.h b/src/guid_diff.h new file mode 100644 index 0000000..fc4d4c8 --- /dev/null +++ b/src/guid_diff.h @@ -0,0 +1,62 @@ +#pragma once +// guid_diff — the pure, REAPER-free core of the D2 Wave-2 new-content detection. +// +// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO +// vendor/ includes. Standard library only. Unit-tested outside the DAW. +// +// The shell (bank_panel timer) reads REAPER's live track/item GUID set each tick; +// this module owns the DECISION of "which GUIDs are new since the last tick" and the +// first-poll-after-open guard so pre-existing content is never mass-tagged. Keeping +// this here — rather than in the shell — means the fiddly baseline/diff logic is +// unit-tested, mirroring how view_tree splits the folder-depth walk out of view.cpp. +// +// The shell then hands the "new since last tick" GUIDs to the pure autoTagNewContent +// (view_mode_model) to produce the membership writes. + +#include +#include +#include + +namespace reasampler { + +// The GUIDs present in `current` but absent from `previous` — i.e. new since the +// previous poll. Order is the set's ascending order (deterministic; the caller does +// not depend on discovery order). Empty GUIDs are ignored (a GUID read failure at the +// shell boundary must never be tagged). +std::vector newGuids(const std::set& previous, + const std::set& current); + +// Tracks the live GUID set across polls for ONE project, implementing the +// first-poll-after-open guard: the first observation after a (re)start establishes a +// BASELINE and reports NOTHING new, so pre-existing content stays at its default +// (Arrange) rather than being mass-tagged. Every subsequent observe() returns only the +// GUIDs created since the prior observe(). +// +// Project switches are handled by reset(): the shell detects a project change (the +// active ReaProject* / project GUID changed) and calls reset() so the next observe() +// re-baselines against the newly-opened project instead of diffing across two +// unrelated projects (which would spuriously "detect" the entire new project as new +// content, or miss content because a same-GUID collision looked pre-existing). +class GuidBaseline { +public: + // Observes the current live GUID set. On the FIRST call after construction or + // reset() this records the baseline and returns {} (nothing is "new" at open). + // On every later call it returns the GUIDs added since the previous call and + // advances the baseline to `current`. Empty GUIDs are ignored. + std::vector observe(const std::set& current); + + // Re-arms the first-poll guard: the next observe() re-baselines and reports + // nothing new. Called on a project switch so detection never diffs across + // projects. + void reset(); + + // True until the first observe() after construction/reset — exposed for the shell + // to reason about (and for tests) about whether a baseline is established yet. + bool primed() const { return primed_; } + +private: + std::set baseline_; + bool primed_ = false; // false ⇒ next observe() sets the baseline +}; + +} // namespace reasampler diff --git a/src/lane_keys.cpp b/src/lane_keys.cpp new file mode 100644 index 0000000..923edbc --- /dev/null +++ b/src/lane_keys.cpp @@ -0,0 +1,34 @@ +// lane_keys implementation — pure string convention, no REAPER. See lane_keys.h. + +#include "lane_keys.h" + +#include + +namespace reasampler { + +namespace { +// Does `s` start with the managed-lane prefix? +bool hasManagedPrefix(const std::string& s) { + const std::size_t n = std::strlen(kManagedLanePrefix); + return s.size() >= n && s.compare(0, n, kManagedLanePrefix) == 0; +} +} // namespace + +bool isManagedLaneName(const std::string& laneName) { + return hasManagedPrefix(laneName); +} + +std::optional managedLaneKey(const std::string& laneName) { + if (!hasManagedPrefix(laneName)) return std::nullopt; // manual/unnamed ⇒ no key + // The durable name IS the key (stable across ordinal renumber). Keeping the full + // prefixed name — rather than stripping to the mode id — means the key is globally + // unambiguous and the ownership index's mode field remains the single source of + // truth for which mode owns the lane. + return laneName; +} + +std::string laneNameForMode(const std::string& modeId) { + return std::string(kManagedLanePrefix) + modeId; +} + +} // namespace reasampler diff --git a/src/lane_keys.h b/src/lane_keys.h new file mode 100644 index 0000000..537972d --- /dev/null +++ b/src/lane_keys.h @@ -0,0 +1,61 @@ +#pragma once +// lane_keys — the pure, REAPER-free convention that maps a REAPER fixed lane's +// durable NAME (P_LANENAME:n) to the opaque lane-key the pure view_mode_model uses, +// and the managed/manual heuristic that rides on it. +// +// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL. std only. +// Unit-tested outside the DAW. The shell (view.cpp) reads each lane's P_LANENAME:n +// string from REAPER and asks this module whether the lane is tool-managed and what +// its stable lane-key is; the shell never re-derives the prefix rule itself. +// +// -- Design point #2 (lane-identity robustness) resolution -------------------- +// +// REAPER exposes no durable per-lane GUID. The only lane identity is the ordinal +// I_FIXEDLANE, which REAPER RENUMBERS when lanes are reordered or deleted — so keying +// the ownership index by raw ordinal would silently corrupt managed/manual ownership +// on any reorder. REAPER DOES expose a writable, durable lane NAME (P_LANENAME:n) that +// travels with the lane across renumber. So the tool names each lane it mints with a +// stable, prefixed identity ("reasampler:") and keys the ownership index by that +// NAME, not the ordinal. On each apply the shell walks the track's lanes by current +// ordinal, reads each name, and reconciles ordinal<->laneKey — so a C_LANEPLAYS:N +// write always targets the lane's CURRENT ordinal for a given durable key even after a +// reorder. A lane WITHOUT the prefix was not minted by the tool: it is manual and +// off-limits (the fixed-lane analog of "never touch mute/solo"). +// +// -- Design point #1 (manual-lane exemption) resolution ----------------------- +// +// The SAME prefix rule is the manual/managed heuristic for auto-tag: an item on a lane +// whose name lacks the "reasampler:" prefix is on a manual lane and is EXEMPT from +// auto-tag. isManagedLaneName is the single predicate both the toggle-apply path and +// the new-content detection path consult, so the boundary is defined in one place and +// unit-tested. + +#include +#include + +namespace reasampler { + +// The prefix the tool stamps on every lane NAME it mints. A lane name carrying this +// prefix is a managed lane the tool created; any other name (or an empty/unnamed lane) +// is a user-minted manual lane. Stable-forever: changing it would strand the ownership +// of every lane in every already-saved project, so treat it like an action id string. +inline constexpr const char* kManagedLanePrefix = "reasampler:"; + +// True iff `laneName` is a tool-minted managed-lane name (carries kManagedLanePrefix). +// This is the load-bearing managed/manual predicate for BOTH design points #1 and #2. +bool isManagedLaneName(const std::string& laneName); + +// The opaque lane-key the pure model keys by, for a lane with REAPER name `laneName`. +// For a managed lane the key IS the durable name (stable across ordinal renumber). For +// a manual/unnamed lane there is no managed key: returns std::nullopt so the caller +// treats the lane as manual (never driven, items on it exempt from auto-tag). +std::optional managedLaneKey(const std::string& laneName); + +// The lane NAME the tool mints for the lane owned by `modeId` (kManagedLanePrefix + +// modeId). The inverse of managedLaneKey for a managed lane: managedLaneKey( +// laneNameForMode(m)) == kManagedLanePrefix + m. Exposed for the Wave-3 lane-minting +// path and for tests; the apply path in this wave only READS names, but the round-trip +// contract is asserted here so minting and reading cannot drift. +std::string laneNameForMode(const std::string& modeId); + +} // namespace reasampler diff --git a/src/view.cpp b/src/view.cpp index f7c93b5..b0e2571 100644 --- a/src/view.cpp +++ b/src/view.cpp @@ -10,10 +10,15 @@ #include "view.h" +#include +#include +#include #include #include +#include #include +#include "lane_keys.h" #include "track_guid.h" #include "view_tree.h" @@ -22,19 +27,29 @@ #define REAPERAPI_WANT_GetTrack #define REAPERAPI_WANT_GetMediaTrackInfo_Value #define REAPERAPI_WANT_SetMediaTrackInfo_Value +#define REAPERAPI_WANT_GetSetMediaTrackInfo_String #define REAPERAPI_WANT_TrackFX_GetCount #define REAPERAPI_WANT_TrackFX_GetOffline #define REAPERAPI_WANT_TrackFX_SetOffline +#define REAPERAPI_WANT_CountTrackMediaItems +#define REAPERAPI_WANT_GetTrackMediaItem +#define REAPERAPI_WANT_GetMediaItemInfo_Value +#define REAPERAPI_WANT_SetMediaItemInfo_Value #define REAPERAPI_WANT_Undo_BeginBlock2 #define REAPERAPI_WANT_Undo_EndBlock2 #define REAPERAPI_WANT_TrackList_AdjustWindows #define REAPERAPI_WANT_UpdateArrange +#define REAPERAPI_WANT_UpdateTimeline #include "reaper_plugin_functions.h" namespace reasampler { namespace { +// Track fixed-lane mode value (I_FREEMODE=2). See SDK: 0=normal, 1=free item +// positioning, 2=fixed lanes. +constexpr int kFreeModeFixedLanes = 2; + // The parmname for each planner Flag. All four are documented bool*/int* track // info params driven through the double-valued Get/SetMediaTrackInfo_Value API. const char* flagParm(Flag f) { @@ -132,6 +147,103 @@ void restoreFxOffline(MediaTrack* tr, const std::vector& fxOffline) } } +// -- Managed-lane application (D2 Wave 2) ------------------------------------ +// +// The pure planner emits LanePlayOps keyed by (trackGuid, laneKey) where laneKey is +// the lane's DURABLE name (lane_keys convention: "reasampler:"). REAPER's +// C_LANEPLAYS:N is keyed by the lane's CURRENT ORDINAL, which renumbers on reorder. +// So before applying, we build the ordinal<->key reconcile for a track by reading each +// lane's P_LANENAME:n; the write then targets the correct current ordinal for a given +// durable key even after a reorder (design point #2). A lane whose name lacks the +// managed prefix is manual and never appears in this map, so it can never be driven. + +// Reads lane index `laneIdx`'s durable name off track `tr` (P_LANENAME:n). Empty if +// the lane is unnamed or the param is unavailable (non-fixed-lane track). +std::string laneName(MediaTrack* tr, int laneIdx) { + char parm[32]; + std::snprintf(parm, sizeof(parm), "P_LANENAME:%d", laneIdx); + char buf[512] = {0}; + if (!GetSetMediaTrackInfo_String(tr, parm, buf, false)) return {}; + return std::string(buf); +} + +// Maps each MANAGED lane's durable key -> its current ordinal on `tr`, by walking the +// track's I_NUMFIXEDLANES lanes and reading each name. Manual (unprefixed/unnamed) +// lanes are omitted, so a key absent from the map is a lane the tool must not drive. +std::map managedLaneOrdinals(MediaTrack* tr) { + std::map byKey; + const int numLanes = static_cast(GetMediaTrackInfo_Value(tr, "I_NUMFIXEDLANES")); + for (int lane = 0; lane < numLanes; ++lane) { + std::optional key = managedLaneKey(laneName(tr, lane)); + if (key) byKey.emplace(*key, lane); // first ordinal wins if names collide + } + return byKey; +} + +// Drives one managed lane on `tr` to `lanePlays` (C_LANEPLAYS value): the track-side +// C_LANEPLAYS:N for lane ordinal `laneIdx`, plus every ITEM on that lane (item-side +// C_LANEPLAYS). Items are matched to the lane by their read-only I_FIXEDLANE ordinal. +// B_FIXEDLANE_HIDDEN is READ-ONLY (SDK) — hide/show follows from C_LANEPLAYS=0/1, never +// written directly. Non-destructive: only reversible play/show flags; no item is moved +// or deleted. +void applyLanePlays(MediaTrack* tr, int laneIdx, int lanePlays) { + char parm[32]; + std::snprintf(parm, sizeof(parm), "C_LANEPLAYS:%d", laneIdx); + SetMediaTrackInfo_Value(tr, parm, static_cast(lanePlays)); + + const int itemCount = CountTrackMediaItems(tr); + for (int i = 0; i < itemCount; ++i) { + MediaItem* it = GetTrackMediaItem(tr, i); + if (!it) continue; + const int itemLane = static_cast(GetMediaItemInfo_Value(it, "I_FIXEDLANE")); + if (itemLane != laneIdx) continue; // item is on a different lane + SetMediaItemInfo_Value(it, "C_LANEPLAYS", static_cast(lanePlays)); + } +} + +// Applies the plan's managed-lane ops. Groups ops by track, resolves each op's durable +// laneKey to the track's current ordinal (skipping any key not present on the live +// track — a stale/renamed/deleted managed lane is pruned, never mis-driven), enables +// fixed-lane mode on any track that carries a managed lane, and drives C_LANEPLAYS. +// UpdateTimeline() is called ONCE at the end (SDK: required after I_FREEMODE changes). +// Returns true if any track's I_FREEMODE was (re)set to fixed lanes (⇒ needs timeline +// refresh). MANAGED lanes only — plan.lanes never contains a manual lane (pure planner +// gates on the ownership index), and a manual lane's name never resolves to a key here, +// so the invariant is enforced twice. +bool applyLaneOps(const std::vector>& handleByGuid, + const std::vector& lanes) { + if (lanes.empty()) return false; + + // Group op indices by track guid so we read each track's lane map once. + std::map> byTrack; + for (const LanePlayOp& op : lanes) byTrack[op.trackGuid].push_back(&op); + + bool touchedFreeMode = false; + for (const auto& [guid, ops] : byTrack) { + MediaTrack* tr = resolve(handleByGuid, guid); + if (!tr) continue; // stale GUID — prune + + // Ensure fixed-lane mode is on before driving lane play state. A track carrying + // a managed lane must be in I_FREEMODE=2; set it only if not already, and flag + // that a timeline refresh is owed. + const int freeMode = static_cast(GetMediaTrackInfo_Value(tr, "I_FREEMODE")); + if (freeMode != kFreeModeFixedLanes) { + SetMediaTrackInfo_Value(tr, "I_FREEMODE", + static_cast(kFreeModeFixedLanes)); + touchedFreeMode = true; + } + + // Reconcile durable keys -> current ordinals on THIS track, then drive each op. + const std::map ordinals = managedLaneOrdinals(tr); + for (const LanePlayOp* op : ops) { + auto it = ordinals.find(op->laneKey); + if (it == ordinals.end()) continue; // key not live on this track — prune + applyLanePlays(tr, it->second, op->lanePlays); + } + } + return touchedFreeMode; +} + } // namespace bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject* proj) { @@ -194,6 +306,16 @@ bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject model.clearSnapshot(guid); } + // MANAGED LANES (D2 item-level projection): drive C_LANEPLAYS so the active mode's + // managed lane plays+shows and every inactive-mode managed lane is silenced+hidden. + // plan.lanes carries MANAGED lanes only (the pure planner gates on the ownership + // index); applyLaneOps additionally resolves each op's durable key against the live + // track's lane names, so a manual lane — which never carries the managed prefix — + // can never be driven. Empty for a D1-only project (no fixed lanes), leaving D1 + // behavior byte-identical. UpdateTimeline() is owed only if a track's I_FREEMODE + // was (re)set to fixed lanes (SDK requirement); deferred to the refresh block below. + const bool laneModeChanged = applyLaneOps(handleByGuid, plan.lanes); + // PARENT VISIBILITY (never parked): visibleTracks() marks a parent visible when // a descendant leaf is visible in the target mode OR the parent belongs to the // mode by its own membership (untagged folder → Arrange default). Recomputed @@ -228,6 +350,12 @@ bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject TrackList_AdjustWindows(false); UpdateArrange(); + // A fixed-lane mode change (I_FREEMODE -> 2) requires UpdateTimeline() to take + // visible effect (SDK). Call it only when we actually toggled a track into fixed + // lanes this apply; the C_LANEPLAYS writes themselves are picked up by the arrange + // refresh above. + if (laneModeChanged) UpdateTimeline(); + Undo_EndBlock2(proj, undoLabel.c_str(), -1); return true; } diff --git a/src/view_mode_model.cpp b/src/view_mode_model.cpp index 868d01d..95f6fc8 100644 --- a/src/view_mode_model.cpp +++ b/src/view_mode_model.cpp @@ -1,10 +1,12 @@ #include "view_mode_model.h" #include +#include #include #include #include #include +#include // view_mode_model implementation. // @@ -121,6 +123,13 @@ int laneModeState(const std::string& managedMode, const std::string& activeMode) // and hidden (C_LANEPLAYS = 0). Exclusive membership: only one stance's lane at a // time. Show-both, which keeps a lane audible across modes, is a per-lane opt-out // the shell layers on; the default per-mode decision here is exclusive. + // + // EXCLUSIVITY ASSUMPTION (one managed lane per mode per track): the model assumes a + // given (track, mode) owns AT MOST ONE managed lane. C_LANEPLAYS=1 means "this lane + // plays EXCLUSIVELY" — two lanes on the same track both claiming mode M would both + // be told to play exclusively on M's toggle, which REAPER cannot honor coherently + // (the last write wins in the DAW). The Wave-3 lane-minting path is responsible for + // upholding one-lane-per-(track,mode); planToggle asserts it in debug builds. return managedMode == activeMode ? kLanePlaysExclusive : kLaneSilent; } @@ -319,8 +328,20 @@ TogglePlan ViewModeModel::planToggle(const FolderTree& tree, const std::string& // "never touch mute/solo"). Lane ownership is not a tree property, so this walks the // ownership index directly, not the FolderTree; a project with no fixed lanes leaves // plan.lanes empty and the plan is byte-identical to a D1 plan. +#ifndef NDEBUG + // Debug-time guard for the one-managed-lane-per-mode-per-track exclusivity + // assumption (see laneModeState). Two managed lanes on the same track claiming the + // same mode would both be told to play exclusively on that mode's toggle, which + // REAPER cannot honor. Cheap set membership over the (usually tiny) managed-lane + // set; compiled out of release builds. + std::set> seenTrackMode; // (trackGuid, mode) +#endif for (const auto& [ref, ownership] : lanes_.all()) { if (!ownership.isManaged()) continue; // manual lanes are off-limits +#ifndef NDEBUG + assert(seenTrackMode.insert({ref.trackGuid, *ownership.managedMode}).second && + "two managed lanes on one track claim the same mode (exclusivity broken)"); +#endif const int lanePlays = laneModeState(*ownership.managedMode, targetMode); plan.lanes.push_back(LanePlayOp{ref.trackGuid, ref.laneKey, lanePlays}); } @@ -448,7 +469,8 @@ std::string ViewModeModel::serialize() const { { bool first = true; for (const auto& [guid, mem] : membership_.all()) { - if (!first) out += ','; first = false; + if (!first) out += ','; + first = false; ObjWriter e(out); e.keyStr("guid", guid); e.keyBegin("modes"); @@ -456,7 +478,8 @@ std::string ViewModeModel::serialize() const { { bool mf = true; for (const auto& id : mem.modeIds) { - if (!mf) out += ','; mf = false; + if (!mf) out += ','; + mf = false; writeEscaped(out, id); } } @@ -472,7 +495,8 @@ std::string ViewModeModel::serialize() const { { bool first = true; for (const auto& [guid, snap] : snapshots_) { - if (!first) out += ','; first = false; + if (!first) out += ','; + first = false; ObjWriter e(out); e.keyStr("guid", guid); e.keyRaw("showInTcp", intToStr(snap.showInTcp)); @@ -494,7 +518,8 @@ std::string ViewModeModel::serialize() const { { bool first = true; for (const auto& [ref, ownership] : lanes_.all()) { - if (!first) out += ','; first = false; + if (!first) out += ','; + first = false; ObjWriter e(out); e.keyStr("trackGuid", ref.trackGuid); e.keyStr("laneKey", ref.laneKey); diff --git a/tests/test_guid_diff.cpp b/tests/test_guid_diff.cpp new file mode 100644 index 0000000..7c329e3 --- /dev/null +++ b/tests/test_guid_diff.cpp @@ -0,0 +1,137 @@ +// Standalone tests for reasampler::newGuids + GuidBaseline — no REAPER, no test +// framework. Mirror of test_view_mode_model: iterate the hard logic outside the DAW. +// +// Covers (D2 Wave-2 new-content detection): +// 1. newGuids: current \ previous, empty-GUID filtering, determinism. +// 2. GuidBaseline first-poll guard: the first observe() after open reports NOTHING +// new (pre-existing content stays Arrange) and establishes the baseline. +// 3. Incremental detection: only GUIDs added since the prior observe() are returned. +// 4. Deletion drops from the baseline so a reused GUID is re-detected. +// 5. reset() (project switch) re-arms the first-poll guard: the next observe() +// re-baselines and reports nothing new — never diffs across projects. + +#include "../src/guid_diff.h" + +#include +#include +#include +#include + +using namespace reasampler; + +static int g_fail = 0; +#define CHECK(cond) do { if(!(cond)) { \ + std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) + +static bool has(const std::vector& v, const std::string& g) { + for (const auto& e : v) if (e == g) return true; + return false; +} + +// -- 1. newGuids set difference ---------------------------------------------- + +static void testNewGuidsDifference() { + std::set prev{"{A}", "{B}"}; + std::set cur{"{A}", "{B}", "{C}", "{D}"}; + + auto added = newGuids(prev, cur); + CHECK(added.size() == 2); + CHECK(has(added, "{C}")); + CHECK(has(added, "{D}")); + CHECK(!has(added, "{A}")); // pre-existing, not new + CHECK(!has(added, "{B}")); + + // No change ⇒ nothing new. + CHECK(newGuids(cur, cur).empty()); + + // A removed GUID is not "new" (it is absent from current). + std::set shrunk{"{A}"}; + CHECK(newGuids(prev, shrunk).empty()); + + // Determinism: ascending set order. + std::set p2; + std::set c2{"{Z}", "{A}", "{M}"}; + auto ordered = newGuids(p2, c2); + CHECK(ordered.size() == 3); + CHECK(ordered[0] == "{A}" && ordered[1] == "{M}" && ordered[2] == "{Z}"); +} + +static void testNewGuidsIgnoresEmpty() { + std::set prev{"{A}"}; + std::set cur{"", "{A}", "{B}"}; // empty ⇒ a GUID-read failure + auto added = newGuids(prev, cur); + CHECK(added.size() == 1); + CHECK(has(added, "{B}")); + CHECK(!has(added, "")); // never tag an empty GUID +} + +// -- 2. First-poll guard ----------------------------------------------------- + +static void testBaselineFirstPollReportsNothing() { + GuidBaseline b; + CHECK(!b.primed()); + // First observe after open: pre-existing content must NOT be tagged. + auto first = b.observe({"{A}", "{B}", "{C}"}); + CHECK(first.empty()); // nothing new at open + CHECK(b.primed()); +} + +// -- 3. Incremental detection ------------------------------------------------ + +static void testBaselineIncremental() { + GuidBaseline b; + b.observe({"{A}", "{B}"}); // baseline + auto t1 = b.observe({"{A}", "{B}", "{C}"}); + CHECK(t1.size() == 1 && has(t1, "{C}")); // only the newly-added GUID + + // Next tick with a further addition — earlier-added {C} is now baseline. + auto t2 = b.observe({"{A}", "{B}", "{C}", "{D}"}); + CHECK(t2.size() == 1 && has(t2, "{D}")); + CHECK(!has(t2, "{C}")); + + // A steady state reports nothing new. + CHECK(b.observe({"{A}", "{B}", "{C}", "{D}"}).empty()); +} + +// -- 4. Deletion drops from baseline; reused GUID re-detected ---------------- + +static void testBaselineDeletionReDetect() { + GuidBaseline b; + b.observe({"{A}", "{B}"}); + // Delete {B}: not "new", and drops out of the baseline. + CHECK(b.observe({"{A}"}).empty()); + // {B} reappears (REAPER reused the GUID or the user re-added) ⇒ detected again. + auto again = b.observe({"{A}", "{B}"}); + CHECK(again.size() == 1 && has(again, "{B}")); +} + +// -- 5. reset() re-arms the first-poll guard (project switch) ----------------- + +static void testResetReBaselines() { + GuidBaseline b; + b.observe({"{A}"}); // project 1 baseline + b.observe({"{A}", "{B}"}); // {B} detected in project 1 + + b.reset(); + CHECK(!b.primed()); + // Switching to project 2: its pre-existing content must NOT be mass-tagged even + // though those GUIDs were never seen before reset. + auto afterSwitch = b.observe({"{X}", "{Y}", "{Z}"}); + CHECK(afterSwitch.empty()); // re-baselined, nothing new + CHECK(b.primed()); + // Content created in project 2 after the switch IS detected. + auto p2new = b.observe({"{X}", "{Y}", "{Z}", "{W}"}); + CHECK(p2new.size() == 1 && has(p2new, "{W}")); +} + +int main() { + testNewGuidsDifference(); + testNewGuidsIgnoresEmpty(); + testBaselineFirstPollReportsNothing(); + testBaselineIncremental(); + testBaselineDeletionReDetect(); + testResetReBaselines(); + + if (g_fail == 0) std::printf("All tests passed.\n"); + return g_fail ? 1 : 0; +} diff --git a/tests/test_lane_keys.cpp b/tests/test_lane_keys.cpp new file mode 100644 index 0000000..f9bd58d --- /dev/null +++ b/tests/test_lane_keys.cpp @@ -0,0 +1,69 @@ +// Standalone tests for reasampler::lane_keys — no REAPER, no test framework. The pure +// managed/manual lane-name heuristic that resolves D2 design points #1 (auto-tag +// exemption) and #2 (durable lane identity vs ordinal renumber). +// +// Covers: +// 1. isManagedLaneName: only the "reasampler:" prefix is managed; everything else +// (empty, user comp names, near-miss prefixes) is manual. +// 2. managedLaneKey: managed name -> its durable key; manual/unnamed -> nullopt. +// 3. Round-trip: managedLaneKey(laneNameForMode(m)) == "reasampler:" + m, so the +// Wave-3 minting path and the read path cannot drift. + +#include "../src/lane_keys.h" + +#include +#include + +using namespace reasampler; + +static int g_fail = 0; +#define CHECK(cond) do { if(!(cond)) { \ + std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) + +static void testIsManagedLaneName() { + // Tool-minted managed names. + CHECK(isManagedLaneName("reasampler:design")); + CHECK(isManagedLaneName("reasampler:arrange")); + CHECK(isManagedLaneName("reasampler:")); // prefix alone still ours (odd but managed) + + // Manual / user lanes are never managed. + CHECK(!isManagedLaneName("")); // unnamed lane ⇒ manual + CHECK(!isManagedLaneName("Comp 1")); // user comp lane + CHECK(!isManagedLaneName("Lead vocal")); + CHECK(!isManagedLaneName("reasample")); // near-miss, no colon ⇒ not ours + CHECK(!isManagedLaneName("Reasampler:design")); // case-sensitive prefix + CHECK(!isManagedLaneName(" reasampler:x")); // leading space ⇒ not a prefix match +} + +static void testManagedLaneKey() { + // Managed lane: the durable name IS the key. + auto k = managedLaneKey("reasampler:design"); + CHECK(k.has_value() && *k == "reasampler:design"); + + // Manual / unnamed lanes have no managed key (⇒ treated as manual, never driven). + CHECK(!managedLaneKey("").has_value()); + CHECK(!managedLaneKey("Comp 1").has_value()); + CHECK(!managedLaneKey("guitar-double").has_value()); +} + +static void testRoundTrip() { + // Minting then reading must agree: managedLaneKey(laneNameForMode(m)) recovers the + // prefixed name for every mode id. + for (const std::string mode : {std::string("arrange"), std::string("design"), + std::string("mixdown")}) { + const std::string name = laneNameForMode(mode); + CHECK(name == "reasampler:" + mode); + CHECK(isManagedLaneName(name)); + auto key = managedLaneKey(name); + CHECK(key.has_value() && *key == "reasampler:" + mode); + } +} + +int main() { + testIsManagedLaneName(); + testManagedLaneKey(); + testRoundTrip(); + + 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 53b0de9..7443962 100644 --- a/tests/test_view_mode_model.cpp +++ b/tests/test_view_mode_model.cpp @@ -940,6 +940,40 @@ static void testLaneOwnershipIndex() { CHECK(!idx.remove("{T}", "l0")); } +// -- D2.2b Last-writer-wins ownership replace (round-trip) -------------------- +// +// setManual then setManaged on the SAME (guid, laneKey) must leave EXACTLY ONE +// managed entry — the ownership record is replaced, not accumulated. Guards the +// "retag a lane the tool now owns" contract and its persistence: the replace must +// survive a serialize/deserialize round-trip with no stray manual duplicate. +static void testLaneOwnershipLastWriterWins() { + ViewModeModel vm; + + // Manual first, then managed on the same lane — the managed write replaces. + CHECK(vm.lanes().setManual("{T}", "l0")); + CHECK(vm.lanes().setManaged("{T}", "l0", kDesignModeId)); + CHECK(vm.lanes().size() == 1); // one entry, not two + const LaneOwnership* o = vm.lanes().query("{T}", "l0"); + CHECK(o && o->isManaged() && *o->managedMode == kDesignModeId); + + // The reverse also replaces: managed -> manual leaves exactly one manual entry. + CHECK(vm.lanes().setManual("{T}", "l0")); + CHECK(vm.lanes().size() == 1); + const LaneOwnership* m = vm.lanes().query("{T}", "l0"); + CHECK(m && m->isManual()); + + // Back to managed, then round-trip: exactly one managed entry survives, no stray + // manual duplicate resurrected by (de)serialization. + CHECK(vm.lanes().setManaged("{T}", "l0", kArrangeModeId)); + auto back = ViewModeModel::deserialize(vm.serialize()); + CHECK(back.has_value()); + if (back) { + CHECK(back->lanes().size() == 1); + const LaneOwnership* r = back->lanes().query("{T}", "l0"); + CHECK(r && r->isManaged() && *r->managedMode == kArrangeModeId); + } +} + // -- D2.3/D2.4 Managed-only: planner + query never emit a manual lane -------- // // Required case: a track with a manual lane + managed mode lanes — neither the planner @@ -1124,6 +1158,7 @@ int main() { // D2 two-canvas lane extension testLaneModeStateAndPlayValues(); testLaneOwnershipIndex(); + testLaneOwnershipLastWriterWins(); testManagedOnlyPlannerAndQuery(); testAutoTagDecision(); testLaneJsonRoundTrip();