Merge: D2 Wave 2 — shell lane application + timer-diff auto-tag detection

This commit is contained in:
2026-07-23 18:25:41 -04:00
11 changed files with 841 additions and 4 deletions
+32
View File
@@ -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
@@ -152,6 +174,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)
@@ -197,6 +227,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 tail_control realtime_record)
+167
View File
@@ -32,6 +32,8 @@
#include <cstdint>
#include <filesystem>
#include <map>
#include <set>
#include <string>
#include <unordered_map>
#include <vector>
@@ -39,11 +41,15 @@
#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 "tail_control.h" // TailSetting, cycleTailMode, tailToggleLabel (pure)
#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).
@@ -74,6 +80,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.
@@ -206,6 +222,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;
@@ -591,6 +621,137 @@ 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).
// True iff `tr` has I_FREEMODE==2 (fixed lanes enabled). The SDK value is verified
// in view.cpp (kFreeModeFixedLanes=2); reproduced here as a local constant so
// bank_panel.cpp stays self-contained without pulling in view.cpp's private namespace.
constexpr int kFreeModeFixedLanes = 2;
bool isFixedLaneTrack(MediaTrack* tr) {
return static_cast<int>(GetMediaTrackInfo_Value(tr, "I_FREEMODE")) == kFreeModeFixedLanes;
}
// Reads the durable P_LANENAME for the lane that item `it` sits on. Returns empty if
// the lane is unnamed or P_LANENAME is unavailable. Callers must already know the track
// is a fixed-lane track (I_FREEMODE==2) before calling this — the manual/non-manual
// distinction only applies there. On a non-fixed-lane track `I_FIXEDLANE` is
// meaningless; the isOnManualLane predicate handles that case via its isFixedLaneTrack
// argument, so callers should not call this at all for non-fixed-lane tracks.
std::string itemLaneName(MediaTrack* tr, MediaItem* it) {
const int laneIdx = static_cast<int>(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.
//
// Manual-lane classification uses the single pure predicate isOnManualLane(isFixedLaneTrack,
// laneName) from lane_keys — the same predicate the apply path consults — so the exemption
// rule is defined in exactly one place and is unit-tested there.
void enumerateLiveGuids(ReaProject* proj, std::set<std::string>& allGuids,
std::map<std::string, bool>& 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);
// Compute the fixed-lane status once per track (not per item) — I_FREEMODE is a
// track-level attribute and is the same for every item on the track.
const bool fixedLane = isFixedLaneTrack(tr);
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);
// Classify via the single shared predicate. For a fixed-lane track we read
// the item's lane name; for a normal track we pass "" (isOnManualLane returns
// false immediately for non-fixed-lane tracks regardless of name).
const std::string ln = fixedLane ? itemLaneName(tr, it) : std::string{};
itemOnManualLane[ig] = isOnManualLane(fixedLane, 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.
//
// INTENTIONAL: membership mutation happens OUTSIDE any Undo block. Auto-tag is a
// background metadata update (like setting a label), not a destructive project edit.
// 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;
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<std::string> live;
std::map<std::string, bool> itemOnManualLane;
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
// 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<std::string> newTracks;
std::vector<NewItem> 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<AutoTag> 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
@@ -981,6 +1142,12 @@ std::vector<std::string> 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.
+44
View File
@@ -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 <algorithm>
namespace reasampler {
std::vector<std::string> newGuids(const std::set<std::string>& previous,
const std::set<std::string>& current) {
std::vector<std::string> 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<std::string> GuidBaseline::observe(const std::set<std::string>& 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<std::string> 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
+62
View File
@@ -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 <set>
#include <string>
#include <vector>
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<std::string> newGuids(const std::set<std::string>& previous,
const std::set<std::string>& 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<std::string> observe(const std::set<std::string>& 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<std::string> baseline_;
bool primed_ = false; // false ⇒ next observe() sets the baseline
};
} // namespace reasampler
+44
View File
@@ -0,0 +1,44 @@
// lane_keys implementation — pure string convention, no REAPER. See lane_keys.h.
#include "lane_keys.h"
#include <cstring>
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<std::string> 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;
}
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.
if (!isFixedLaneTrack) return false;
// On a fixed-lane track: a managed lane (prefixed) is NOT manual; everything else
// — including the empty/unnamed lane that REAPER creates by default — IS manual
// (user-minted, off-limits to auto-tag and to the lane-drive path).
return !hasManagedPrefix(laneName);
}
} // namespace reasampler
+77
View File
@@ -0,0 +1,77 @@
#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:<mode>") 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 <optional>
#include <string>
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<std::string> 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);
// 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
// (non-fixed-lane) track the concept of a "manual lane" does not
// apply; the item follows the normal auto-tag rule (return false).
// laneName — the durable P_LANENAME of the lane the item sits on. A lane
// that carries kManagedLanePrefix is a tool-minted managed lane
// (not manual); any other name — including empty (unnamed) — is
// a user-minted manual lane (exempt from auto-tag).
//
// This is the SINGLE predicate that governs BOTH the apply path (which lanes may be
// driven) and the auto-tag exemption path (which items are exempt). It is unit-tested
// here so both paths share exactly one definition; the shell supplies the two REAPER
// inputs (I_FREEMODE result, P_LANENAME string) and never re-derives this logic.
bool isOnManualLane(bool isFixedLaneTrack, const std::string& laneName);
} // namespace reasampler
+120
View File
@@ -10,10 +10,15 @@
#include "view.h"
#include <cstdio>
#include <map>
#include <optional>
#include <set>
#include <string>
#include <utility>
#include <vector>
#include "lane_keys.h"
#include "track_guid.h"
#include "view_tree.h"
@@ -22,6 +27,7 @@
#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
@@ -29,12 +35,17 @@
#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 +143,99 @@ void restoreFxOffline(MediaTrack* tr, const std::vector<FxOfflineOp>& 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:<mode>"). 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<std::string, int> managedLaneOrdinals(MediaTrack* tr) {
std::map<std::string, int> byKey;
const int numLanes = static_cast<int>(GetMediaTrackInfo_Value(tr, "I_NUMFIXEDLANES"));
for (int lane = 0; lane < numLanes; ++lane) {
std::optional<std::string> 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) via the
// TRACK-SIDE C_LANEPLAYS:N write. Track-side C_LANEPLAYS:N alone produces the
// hide+silence effect for all items on lane N — no per-item write is needed or
// possible (item-side C_LANEPLAYS is marked read-only in the SDK).
// 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.
//
// DAW-VERIFY: confirm that track-side C_LANEPLAYS:N alone hides+silences all items
// on lane N without a per-item write. (SDK marks item-side C_LANEPLAYS as read-only;
// the track-side write is the documented mechanism.)
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<double>(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<std::pair<std::string, MediaTrack*>>& handleByGuid,
const std::vector<LanePlayOp>& lanes) {
if (lanes.empty()) return false;
// Group op indices by track guid so we read each track's lane map once.
std::map<std::string, std::vector<const LanePlayOp*>> 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<int>(GetMediaTrackInfo_Value(tr, "I_FREEMODE"));
if (freeMode != kFreeModeFixedLanes) {
SetMediaTrackInfo_Value(tr, "I_FREEMODE",
static_cast<double>(kFreeModeFixedLanes));
touchedFreeMode = true;
}
// Reconcile durable keys -> current ordinals on THIS track, then drive each op.
const std::map<std::string, int> 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 +298,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 +342,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;
}
+30 -4
View File
@@ -1,10 +1,13 @@
#include "view_mode_model.h"
#include <algorithm>
#include <cassert>
#include <cerrno>
#include <climits>
#include <cstdio>
#include <cstdlib>
#include <set>
#include <utility>
// view_mode_model implementation.
//
@@ -121,6 +124,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 +329,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<std::pair<std::string, std::string>> 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 +470,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 +479,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 +496,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 +519,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);
+137
View File
@@ -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 <cstdio>
#include <set>
#include <string>
#include <vector>
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<std::string>& 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<std::string> prev{"{A}", "{B}"};
std::set<std::string> 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<std::string> shrunk{"{A}"};
CHECK(newGuids(prev, shrunk).empty());
// Determinism: ascending set order.
std::set<std::string> p2;
std::set<std::string> 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<std::string> prev{"{A}"};
std::set<std::string> 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;
}
+93
View File
@@ -0,0 +1,93 @@
// 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 <cstdio>
#include <string>
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 testIsOnManualLane() {
// Non-fixed-lane track: concept does not apply regardless of name.
CHECK(!isOnManualLane(false, "")); // normal track, unnamed ⇒ not manual
CHECK(!isOnManualLane(false, "Comp 1")); // normal track, user name ⇒ not manual
CHECK(!isOnManualLane(false, "reasampler:design")); // normal track, managed name ⇒ not manual
// Fixed-lane track: managed lane (tool-prefixed) ⇒ NOT manual (tool drives it).
CHECK(!isOnManualLane(true, "reasampler:design"));
CHECK(!isOnManualLane(true, "reasampler:arrange"));
CHECK(!isOnManualLane(true, "reasampler:")); // prefix-only: still managed
// Fixed-lane track: unnamed lane (empty P_LANENAME) ⇒ manual.
// REAPER starts fixed lanes unnamed; an item on an unnamed fixed lane is a user
// comp lane and must be exempt from auto-tag.
CHECK(isOnManualLane(true, ""));
// Fixed-lane track: user-named but non-managed ⇒ manual.
CHECK(isOnManualLane(true, "Comp 1"));
CHECK(isOnManualLane(true, "Lead vocal"));
CHECK(isOnManualLane(true, "reasample")); // near-miss, no colon ⇒ manual
CHECK(isOnManualLane(true, "Reasampler:x")); // wrong case ⇒ manual
}
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();
testIsOnManualLane();
testRoundTrip();
if (g_fail == 0) std::printf("All tests passed.\n");
return g_fail ? 1 : 0;
}
+35
View File
@@ -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();