From af34143107e5f6bf0f55752d734d62fbfa78b9e6 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Thu, 23 Jul 2026 17:32:22 -0400 Subject: [PATCH 01/22] =?UTF-8?q?feat(view):=20D2=20Wave=202=20=E2=80=94?= =?UTF-8?q?=20apply=20managed-lane=20ops=20+=20timer-diff=20auto-tag=20new?= =?UTF-8?q?=20content?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drives fixed-lane C_LANEPLAYS for managed lanes only (name-keyed, ordinal-renumber safe) in the view shell; diffs live track/item GUIDs on the panel timer to auto-tag new content to the active mode, first-poll-guarded. Pure guid_diff + lane_keys modules unit-tested; folds in Wave-1 polish. --- CMakeLists.txt | 32 ++++++++ src/bank_panel.cpp | 142 +++++++++++++++++++++++++++++++++ src/guid_diff.cpp | 44 ++++++++++ src/guid_diff.h | 62 ++++++++++++++ src/lane_keys.cpp | 34 ++++++++ src/lane_keys.h | 61 ++++++++++++++ src/view.cpp | 128 +++++++++++++++++++++++++++++ src/view_mode_model.cpp | 33 +++++++- tests/test_guid_diff.cpp | 137 +++++++++++++++++++++++++++++++ tests/test_lane_keys.cpp | 69 ++++++++++++++++ tests/test_view_mode_model.cpp | 35 ++++++++ 11 files changed, 773 insertions(+), 4 deletions(-) create mode 100644 src/guid_diff.cpp create mode 100644 src/guid_diff.h create mode 100644 src/lane_keys.cpp create mode 100644 src/lane_keys.h create mode 100644 tests/test_guid_diff.cpp create mode 100644 tests/test_lane_keys.cpp 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(); From ebdb4eae66ba62ca4826c1dfc89db9bd45f69d79 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Thu, 23 Jul 2026 17:51:55 -0400 Subject: [PATCH 02/22] fix(D2-W2): correct manual-lane exemption, drop read-only item C_LANEPLAYS write isOnManualLane predicate now correctly treats unnamed fixed-lane lanes as manual; item-side C_LANEPLAYS loop removed (SDK read-only); explicit #include in view_mode_model.cpp; undo-block omission in detectNewContent documented as intentional. --- src/bank_panel.cpp | 43 +++++++++++++++++++++++++++++++--------- src/lane_keys.cpp | 10 ++++++++++ src/lane_keys.h | 16 +++++++++++++++ src/view.cpp | 30 ++++++++++------------------ src/view_mode_model.cpp | 1 + tests/test_lane_keys.cpp | 24 ++++++++++++++++++++++ 6 files changed, 96 insertions(+), 28 deletions(-) diff --git a/src/bank_panel.cpp b/src/bank_panel.cpp index 29f80c6..eee2e2c 100644 --- a/src/bank_panel.cpp +++ b/src/bank_panel.cpp @@ -566,13 +566,22 @@ bool refreshFingerprint() { // 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. +// 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(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) { - 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); @@ -592,6 +601,10 @@ std::string itemGuid(MediaItem* it) { // 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& allGuids, std::map& itemOnManualLane) { const int trackCount = CountTracks(proj); @@ -601,6 +614,10 @@ void enumerateLiveGuids(ReaProject* proj, std::set& allGuids, 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); @@ -608,9 +625,11 @@ void enumerateLiveGuids(ReaProject* proj, std::set& allGuids, 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)); + // 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); } } } @@ -619,6 +638,12 @@ void enumerateLiveGuids(ReaProject* proj, std::set& allGuids, // 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; diff --git a/src/lane_keys.cpp b/src/lane_keys.cpp index 923edbc..83b4c7a 100644 --- a/src/lane_keys.cpp +++ b/src/lane_keys.cpp @@ -31,4 +31,14 @@ 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 diff --git a/src/lane_keys.h b/src/lane_keys.h index 537972d..29ffee1 100644 --- a/src/lane_keys.h +++ b/src/lane_keys.h @@ -58,4 +58,20 @@ std::optional managedLaneKey(const std::string& laneName); // 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 diff --git a/src/view.cpp b/src/view.cpp index b0e2571..73d90aa 100644 --- a/src/view.cpp +++ b/src/view.cpp @@ -31,10 +31,6 @@ #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 @@ -180,25 +176,21 @@ std::map managedLaneOrdinals(MediaTrack* tr) { 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. +// 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(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 diff --git a/src/view_mode_model.cpp b/src/view_mode_model.cpp index 95f6fc8..409c0a5 100644 --- a/src/view_mode_model.cpp +++ b/src/view_mode_model.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include // view_mode_model implementation. diff --git a/tests/test_lane_keys.cpp b/tests/test_lane_keys.cpp index f9bd58d..5f33e6f 100644 --- a/tests/test_lane_keys.cpp +++ b/tests/test_lane_keys.cpp @@ -46,6 +46,29 @@ static void testManagedLaneKey() { 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. @@ -62,6 +85,7 @@ static void testRoundTrip() { int main() { testIsManagedLaneName(); testManagedLaneKey(); + testIsOnManualLane(); testRoundTrip(); if (g_fail == 0) std::printf("All tests passed.\n"); From f2bfe466ecf740c773425e8b86ec612f94ee2f95 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Thu, 23 Jul 2026 18:25:45 -0400 Subject: [PATCH 03/22] T2: realtime capture tail trimming Auto/Manual/Off tail handling for realtime track-tap capture. Auto scans recorded PCM backward for last frame above -72 dB and truncates the wav header-aware (8 s cap); Manual pads a fixed tail; Off is byte-identical. wav_trim rejects WAVE_FORMAT_EXTENSIBLE with non-float SubFormat GUID. --- CMakeLists.txt | 20 +- src/capture_realtime.cpp | 187 ++++++++++++++++- src/main.cpp | 22 +- src/peaks.cpp | 28 +++ src/peaks.h | 38 ++++ src/render_settings.cpp | 18 ++ src/render_settings.h | 14 ++ src/wav_trim.cpp | 160 ++++++++++++++ src/wav_trim.h | 101 +++++++++ tests/test_peaks.cpp | 75 +++++++ tests/test_render_settings.cpp | 28 +++ tests/test_wav_trim.cpp | 372 +++++++++++++++++++++++++++++++++ 12 files changed, 1053 insertions(+), 10 deletions(-) create mode 100644 src/wav_trim.cpp create mode 100644 src/wav_trim.h create mode 100644 tests/test_wav_trim.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 76b76d6..75c4f97 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -120,6 +120,20 @@ add_library(realtime_record STATIC src/realtime_record.cpp) target_include_directories(realtime_record PUBLIC src) target_link_libraries(realtime_record PUBLIC bank_model) +# --------------------------------------------------------------------------- +# 2h) Pure wav_trim library — NO REAPER, NO SWELL. The realtime tail's (T2) PCM +# decay-scan trim needs to TRUNCATE the recorded 32-bit-float WAV at a frame +# boundary without corrupting the RIFF container. This module holds the fiddly, +# easy-to-get-wrong part unit-tested outside the DAW: parse the WAV geometry +# (fmt/data chunk walk + 32-bit-float verification), extract the tail-region +# floats to scan, and compute the truncate plan (kept byte length + the two +# patched RIFF/data size fields). The file read/write/truncate I/O stays in the +# realtime shell. Depends on peaks for the AudioSample float alias. +# --------------------------------------------------------------------------- +add_library(wav_trim STATIC src/wav_trim.cpp) +target_include_directories(wav_trim PUBLIC src) +target_link_libraries(wav_trim PUBLIC peaks) + # --------------------------------------------------------------------------- # 3) Standalone tests for the pure modules (run without launching REAPER). # --------------------------------------------------------------------------- @@ -168,6 +182,10 @@ add_executable(realtime_record_tests tests/test_realtime_record.cpp) target_link_libraries(realtime_record_tests PRIVATE realtime_record) add_test(NAME realtime_record_tests COMMAND realtime_record_tests) +add_executable(wav_trim_tests tests/test_wav_trim.cpp) +target_link_libraries(wav_trim_tests PRIVATE wav_trim) +add_test(NAME wav_trim_tests COMMAND wav_trim_tests) + # --------------------------------------------------------------------------- # 4) The REAPER extension — a loadable module (dlopen'd by REAPER, not linked). # --------------------------------------------------------------------------- @@ -199,7 +217,7 @@ add_library(reaper_reasampler MODULE src/track_guid.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) +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 wav_trim) target_include_directories(reaper_reasampler PRIVATE ${SDK_INC} ${WDL_INC}) set_target_properties(reaper_reasampler PROPERTIES PREFIX "" OUTPUT_NAME "reaper_reasampler") diff --git a/src/capture_realtime.cpp b/src/capture_realtime.cpp index d20290f..4635c11 100644 --- a/src/capture_realtime.cpp +++ b/src/capture_realtime.cpp @@ -71,13 +71,18 @@ #include #include +#include #include #include +#include #include #include #include "capture_paths.h" +#include "peaks.h" // lastFrameAboveThreshold, AudioSample #include "realtime_record.h" +#include "render_settings.h" // autoTrimEndRatio, realtimeRecordWindowEnd +#include "wav_trim.h" // parseWavLayout, extractFloatFrames, planWavTruncate #define REAPERAPI_MINIMAL #define REAPERAPI_WANT_EnumProjects @@ -178,6 +183,12 @@ public: BankPaths paths_; std::string uniqueTag_; + // The RECORDED window end in project seconds (>= request_.endSeconds). For a tail + // mode the transport runs PAST the range end (Auto: +8 s cap; Manual: +the set + // length), so this — not request_.endSeconds — is the end the completion state + // machine waits for. Equals request_.endSeconds for TailMode::None (exact bounds). + double recordWindowEnd_ = 0.0; + // The transient sink. The sends we create (from each selected source track INTO // temp_) live on those source tracks pointing AT temp_, and are removed automatically // when temp_ is deleted — REAPER cannot leave a send dangling to a deleted @@ -311,6 +322,128 @@ private: namespace { +// Reads the whole file into a byte buffer. Empty vector on any I/O failure — the +// caller treats an unreadable file as "skip the trim" (keep the untrimmed window), +// never as a corruption of the recorded audio. +std::vector readAllBytes(const std::string& path) { + std::ifstream f(path, std::ios::binary | std::ios::ate); + if (!f) return {}; + const std::streamoff size = f.tellg(); + if (size <= 0) return {}; + std::vector bytes(static_cast(size)); + f.seekg(0); + f.read(reinterpret_cast(bytes.data()), size); + if (!f) return {}; + return bytes; +} + +// Patches a little-endian uint32 into a byte buffer at `off` (the header size fields). +void writeU32LE(std::vector& bytes, std::size_t off, std::uint32_t v) { + bytes[off + 0] = static_cast(v & 0xFF); + bytes[off + 1] = static_cast((v >> 8) & 0xFF); + bytes[off + 2] = static_cast((v >> 16) & 0xFF); + bytes[off + 3] = static_cast((v >> 24) & 0xFF); +} + +// ============================================================================ +// §TAIL — Auto-mode PCM decay-scan trim (docs/product/capture-tail.md §realtime) +// ============================================================================ +// After the recorded file is stable and moved into the bank (the file we OWN — never +// the project), Auto mode trims the trailing decay: read the WAV, scan the tail +// region (frames AFTER the original range end) backward for the last frame above +// -72 dB, and truncate the file there. Rules (spec): +// * no frame in the tail window above -72 dB -> trim back to the original range end +// * signal never falls below -72 dB in window -> keep the full window (cap did its job) +// * otherwise -> trim one frame past the last audible +// +// Returns the trimmed length in SECONDS (for the Sample), or a negative value to +// signal "no trim applied" (caller keeps the pre-trim length). Best-effort and +// non-fatal: any unreadable/unknown/short file skips the trim (keeps the full window) +// rather than risk corrupting the capture — realtime tail is a convenience path. +// +// FORMAT / FLUSH ASSUMPTIONS (DAW-verify): the recorded file is a canonical 32-bit +// float WAV (REAPER project record format — the manual procedure sets it) and is fully +// flushed/closed before this runs (the tick() Finalizing size-stable wait guarantees +// that for the normal path; abort()'s best-effort finalize races it, documented). +double trimAutoTailInPlace(const std::string& path, + double rangeStartSeconds, + double rangeEndSeconds) { + constexpr double kNoTrim = -1.0; + + std::vector bytes = readAllBytes(path); + if (bytes.empty()) return kNoTrim; + + const reasampler::WavLayout layout = parseWavLayout(bytes); + if (!layout.valid || layout.sampleRate == 0) return kNoTrim; // not a WAV we trim + + const std::size_t totalFrames = layout.frameCount(); + if (totalFrames == 0) return kNoTrim; + + // The original range end as a frame index within the file (frame 0 == start). Use + // the FILE's own sample rate (authoritative) — the request rate may be 0 (=follow + // project). Clamp to the file so a rounding overshoot cannot exceed it. + const double rangeSeconds = rangeEndSeconds - rangeStartSeconds; + if (rangeSeconds <= 0.0) return kNoTrim; + std::size_t rangeEndFrame = static_cast( + rangeSeconds * static_cast(layout.sampleRate) + 0.5); + if (rangeEndFrame > totalFrames) rangeEndFrame = totalFrames; + + // Nothing recorded past the range end (the tail window was empty) -> nothing to + // trim; keep as-is. (Shouldn't happen for Auto, but total by construction.) + if (rangeEndFrame >= totalFrames) return kNoTrim; + + // Scan ONLY the tail region (frames after the original range end). The trim never + // eats into the range body — the scan starts at rangeEndFrame. + const std::size_t tailFrames = totalFrames - rangeEndFrame; + const std::vector tailPcm = + extractFloatFrames(bytes, layout, rangeEndFrame, tailFrames); + if (tailPcm.empty()) return kNoTrim; + + const float threshold = static_cast(reasampler::autoTrimEndRatio()); + const std::size_t lastAbove = reasampler::lastFrameAboveThreshold( + tailPcm, layout.channelCount, tailFrames, threshold); + + // keptFrames: the total frame count the trimmed file retains. + // no audible tail frame -> trim back to the range end (rangeEndFrame frames) + // an audible frame at idx -> keep range body + up to and including that frame + // The "signal never falls below threshold" case falls out naturally: lastAbove is + // the final tail frame, so keptFrames == totalFrames (the full window is kept). + std::size_t keptFrames; + if (lastAbove == reasampler::kNoFrameAboveThreshold) { + keptFrames = rangeEndFrame; + } else { + keptFrames = rangeEndFrame + (lastAbove + 1); + } + if (keptFrames >= totalFrames) return kNoTrim; // full window kept -> no truncate + + const reasampler::WavTruncatePlan plan = planWavTruncate(layout, keptFrames); + if (!plan.valid) return kNoTrim; + + // Patch the RIFF + data size fields in the in-memory buffer so they describe the + // kept frame count, then rewrite the file as exactly the first newFileByteLength + // bytes (header + patched sizes + retained PCM). A single truncating write is the + // simplest correct truncate — no separate resize step, no partial-write window + // where the on-disk sizes and length disagree. The result is a valid, playable WAV + // of the kept frames (verified by the wav_trim re-parse test). + writeU32LE(bytes, plan.dataSizeFieldOffset, plan.newDataSize); + writeU32LE(bytes, plan.riffSizeFieldOffset, plan.newRiffSize); + + // NOTE (DAW-verify, best-effort): a truncating write that fails MID-write (a full + // disk, a yanked drive) would leave a short file while we return kNoTrim, so the + // Sample length would overstate the file. Vanishingly unlikely for a just-recorded + // local bank file, and realtime tail is a convenience path, so a temp-file+atomic- + // rename is not warranted here; flagged rather than built. + std::ofstream out(path, std::ios::binary | std::ios::trunc); + if (!out) return kNoTrim; // could not reopen to rewrite — leave the full file + out.write(reinterpret_cast(bytes.data()), + static_cast(plan.newFileByteLength)); + if (!out) return kNoTrim; + out.close(); + + // The trimmed length in seconds for the Sample metadata. + return static_cast(keptFrames) / static_cast(layout.sampleRate); +} + // Builds a CaptureResult for a finalized recording: discover the recorded file, // move it into the bank, populate the Sample via the pure mapping. Returns Ok + // Sample on success, or a RenderFailed result. Does NOT restore — the caller @@ -347,6 +480,19 @@ CaptureResult finalizeRecording(RealtimeCaptureState& st) { std::filesystem::remove(recorded, rmEc); // best-effort } + // TAIL (Auto): trim the trailing decay of the recorded window in place — on the + // BANK file we now own (destPath), never the project. Best-effort: an unreadable / + // unknown-format / short file skips the trim (keeps the full window) rather than + // corrupt the capture. Only Auto trims; None recorded exact bounds and Manual is a + // fixed window (spec §The realtime path). Returns the trimmed length in seconds, + // or < 0 for "no trim applied". + double trimmedLenSeconds = -1.0; + if (st.request_.tailMode == TailMode::Auto) { + trimmedLenSeconds = trimAutoTailInPlace(destPath, + st.request_.startSeconds, + st.request_.endSeconds); + } + RecordedCapture cap; cap.relativePath = st.paths_.relativePath; cap.uniqueTag = st.uniqueTag_; @@ -365,9 +511,25 @@ CaptureResult finalizeRecording(RealtimeCaptureState& st) { result.status = CaptureStatus::Ok; result.sample = sampleFromRecordedCapture(cap); + + // The recorded file's true length differs from the request range when a tail was + // recorded, so the Sample length must reflect the FILE, not the range: + // Auto with a trim applied -> the trimmed length trimAutoTailInPlace returned. + // Auto with no trim, or Manual -> the full recorded window (end - start). + // None -> the exact range (unchanged; recordWindowEnd_ == endSeconds). + // sampleFromRecordedCapture already set lengthSeconds = end - start; override it + // to the recorded/trimmed length so downstream (thumbnail, placement) matches disk. + if (trimmedLenSeconds >= 0.0) { + result.sample.lengthSeconds = trimmedLenSeconds; + } else { + result.sample.lengthSeconds = + st.recordWindowEnd_ - st.request_.startSeconds; + } + result.message = "Realtime-captured [" + std::to_string(st.request_.startSeconds) + "s, " + - std::to_string(st.request_.endSeconds) + "s] -> " + + std::to_string(st.request_.endSeconds) + "s] (recorded " + + std::to_string(result.sample.lengthSeconds) + "s) -> " + st.paths_.relativePath; return result; } @@ -448,6 +610,14 @@ RealtimeRecordBackend::begin(const CaptureRequest& request, st->uniqueTag_ = makeUniqueTag(); st->paths_ = deriveBankPaths(projectDir, request.baseName, st->uniqueTag_); + // The recorded window end: extended past the range end for a tail mode (Auto/Manual), + // exact for None. This — not request.endSeconds — is what the completion machine + // waits for; the extra window past the range end is trimmed later (Auto) or kept + // (Manual). Pure mapping (render_settings), shared caps with the offline tail. + st->recordWindowEnd_ = realtimeRecordWindowEnd(request.tailMode, + request.endSeconds, + request.tailMs); + // DELIBERATE: the transient temp-track / arm / send / transport mutations are NOT // wrapped in an Undo_BeginBlock/Undo_EndBlock — divergence from the insert/view // shells is intentional. This backend fully restores its own state across every @@ -514,9 +684,12 @@ RealtimeRecordBackend::begin(const CaptureRequest& request, SetMediaTrackInfo_Value(st->temp_, "I_RECARM", 1.0); // arm ONLY the sink SetMediaTrackInfo_Value(st->temp_, "I_RECMON", 0.0); // no input monitoring - // Record range: time selection over [start,end], play cursor at start. Both were - // snapshotted and will be restored by restore(). - double rs = request.startSeconds, re = request.endSeconds; + // Record range: time selection over [start, recordWindowEnd], play cursor at start. + // recordWindowEnd extends past the request's range end for a tail mode so the + // transport captures the decaying tail; it equals the range end for None (exact + // bounds). Both cursor + time selection were snapshotted and are restored by + // restore(). + double rs = request.startSeconds, re = st->recordWindowEnd_; GetSet_LoopTimeRange(true, false, &rs, &re, false); SetEditCurPos(request.startSeconds, false, false); @@ -567,9 +740,13 @@ RealtimeTickResult RealtimeRecordBackend::tick(RealtimeCaptureState& state) { state.lastFileSize_ = sz; } + // Wait for the transport to reach the RECORDED window end (extended past the + // range end for a tail mode), not the request's range end — the extra tail window + // is part of the record. The record safety ceiling scales with it (window - start + // + margin) inside the pure machine. state.phase_ = advanceRecordPhase(state.phase_, inputs, state.request_.startSeconds, - state.request_.endSeconds); + state.recordWindowEnd_); // On the Recording -> Finalizing edge, stop OUR project's transport ONCE so REAPER // begins closing/flushing the recorded take. Project-scoped (OnStopButtonEx(proj_)) diff --git a/src/main.cpp b/src/main.cpp index 200a11a..5763147 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -596,13 +596,20 @@ static void RunCaptureRealtimeTrack() return; } + // The tail mode is the SAME panel setting the offline capture actions read (the + // docked bank panel's toggle). Realtime honors it via a parallel path: the backend + // records a generous window past the range end, then trims by PCM decay-scan (T2 / + // capture-tail.md §The realtime path) — it does NOT drive RENDER_*. Default None + // keeps realtime exact-bounds / byte-identical to today. + const reasampler::TailSetting tail = reasampler::bankPanelTailSetting(); + reasampler::CaptureRequest req; req.sourceMode = reasampler::SourceMode::SelectedTracks; // realtime track scope req.startSeconds = src.startSeconds; // exact bounds — no rounding req.endSeconds = src.endSeconds; req.wetDry = 1.0; // fully wet (post-fader tap) - req.tailMode = reasampler::TailMode::None; // realtime tail is T2; exact bounds here - req.tailMs = 0.0; + req.tailMode = tail.mode; // None / Auto / Manual, from the panel toggle + req.tailMs = tail.manualMs; // Manual-only (pre-clamped); ignored for None/Auto req.sampleRate = 0; // follow project rate req.channelCount = 2; req.bitDepth = reasampler::WavBitDepth::Float32; @@ -623,8 +630,15 @@ static void RunCaptureRealtimeTrack() // completion across ticks (UI stays responsive). g_rtCaptureProject = EnumProjects(-1, nullptr, 0); g_rtCapture = std::move(st); - ShowConsoleMsg("ReaSampler: realtime capture started — recording in the " - "background; the bank updates when it reaches the range end.\n"); + // With a tail mode the recorded window runs PAST the range end (Auto: +8 s then + // decay-trim; Manual: +the set length), so the completion note names the window, + // not just the range end. + const char* doneWhen = + (tail.mode == reasampler::TailMode::None) + ? "the bank updates when it reaches the range end." + : "the bank updates after the extra tail window (past the range end)."; + ShowConsoleMsg((std::string("ReaSampler: realtime capture started — recording in " + "the background; ") + doneWhen + "\n").c_str()); } // Cancels the in-flight realtime capture on demand (bindable action). Force-terminates diff --git a/src/peaks.cpp b/src/peaks.cpp index d2136fd..af65994 100644 --- a/src/peaks.cpp +++ b/src/peaks.cpp @@ -2,6 +2,7 @@ #include #include +#include // peaks implementation. // @@ -63,4 +64,31 @@ Envelope computeEnvelope(const std::vector& interleaved, return envelope; } +std::size_t lastFrameAboveThreshold(const std::vector& interleaved, + std::size_t channelCount, + std::size_t frameCount, + AudioSample linearThreshold) { + if (channelCount == 0) return kNoFrameAboveThreshold; + + // Clamp to what the buffer actually holds — a caller frameCount that overstates + // the buffer must never read past the end (mirror of computeEnvelope's guard). + const std::size_t availableFrames = interleaved.size() / channelCount; + const std::size_t frames = std::min(frameCount, availableFrames); + if (frames == 0) return kNoFrameAboveThreshold; + + // Scan backward: the first frame (from the end) whose loudest channel exceeds the + // threshold is the last audible frame. `f` runs frames..1 so `f-1` never wraps. + for (std::size_t f = frames; f > 0; --f) { + const std::size_t frame = f - 1; + const std::size_t base = frame * channelCount; + AudioSample peak = 0.0f; + for (std::size_t c = 0; c < channelCount; ++c) { + const AudioSample a = std::fabs(interleaved[base + c]); + peak = std::max(peak, a); + } + if (peak > linearThreshold) return frame; + } + return kNoFrameAboveThreshold; +} + } // namespace reasampler diff --git a/src/peaks.h b/src/peaks.h index f967c7d..dceeee5 100644 --- a/src/peaks.h +++ b/src/peaks.h @@ -66,4 +66,42 @@ Envelope computeEnvelope(const std::vector& interleaved, std::size_t frameCount, std::size_t binCount); +// Sentinel returned by lastFrameAboveThreshold when NO frame in the scanned range +// peaks above the threshold (pure silence at that level). SIZE_MAX is unambiguous: +// no valid frame index can equal it (a real index is < frameCount <= SIZE_MAX for +// any allocatable buffer), so the caller tests `== kNoFrameAboveThreshold` cleanly. +inline constexpr std::size_t kNoFrameAboveThreshold = + static_cast(-1); + +// Scans interleaved PCM BACKWARD for the last frame whose per-frame peak (the max +// absolute value across all channels of that frame — NO stereo fold, just the +// loudest channel that frame) exceeds `linearThreshold`, returning that frame index. +// Returns kNoFrameAboveThreshold if no frame exceeds it (or on degenerate input). +// +// This is the boundary primitive behind the realtime tail's decay-scan trim +// (docs/product/capture-tail.md §The realtime path): the recorded tail window is +// scanned back from the end for the last frame still above -72 dB, and the file is +// truncated one frame past it. Deliberately a separate primitive from +// computeEnvelope — that answers "the min/max envelope over bins" (a thumbnail), +// this answers "the last frame above a level" (a boundary). Bending the bin-oriented +// envelope to a frame-exact boundary question is a worse fit (spec §option a). +// +// interleaved frame-interleaved samples: [f0c0, f0c1, ..., f1c0, f1c1, ...]. +// Must hold >= frameCount * channelCount; extra is ignored, and a +// short buffer is clamped to what it actually holds (no OOB read). +// channelCount channels per frame (the stride). The per-frame test is the max +// |sample| over these channels — the frame is "above" if its +// loudest channel is above the threshold. +// frameCount frames to consider (the scan starts at the last of these). +// linearThreshold the comparison level as a LINEAR amplitude ratio (e.g. the +// -72 dB ratio from render_settings::autoTrimEndRatio), NOT dB. +// A frame counts as above when its peak is STRICTLY > this. +// +// Pure, stdlib-only, unit-tested (a synthetic decaying ramp, silence, all-above, +// and degenerate inputs) so the trim boundary math is locked outside the DAW. +std::size_t lastFrameAboveThreshold(const std::vector& interleaved, + std::size_t channelCount, + std::size_t frameCount, + AudioSample linearThreshold); + } // namespace reasampler diff --git a/src/render_settings.cpp b/src/render_settings.cpp index 3f4e3ac..6a38129 100644 --- a/src/render_settings.cpp +++ b/src/render_settings.cpp @@ -56,6 +56,24 @@ TailRenderSettings tailRenderSettingsFor(TailMode mode, double manualTailMs) { return t; } +double realtimeRecordWindowEnd(TailMode mode, double rangeEndSeconds, + double manualTailMs) { + switch (mode) { + case TailMode::None: + // Exact — no extra recording (byte-identical to today's realtime capture). + return rangeEndSeconds; + case TailMode::Auto: + // The 8 s runaway cap past the range end; the decay-trim shortens it later. + return rangeEndSeconds + kMaxTailSeconds; + case TailMode::Manual: + // Fixed window: range + the set length, clamped to the 8 s cap (the same + // runaway guard the offline Manual path applies). Negative floors to 0. + return rangeEndSeconds + std::clamp(manualTailMs, 0.0, kMaxTailMs) / 1000.0; + } + // Unreachable for a valid enum; fail closed to exact bounds (never a stray tail). + return rangeEndSeconds; +} + RenderSettingsChoice renderSettingsFor(SourceMode mode, double /*wetDry*/) { // `wetDry` is accepted so CaptureRequest.wetDry remains the seam for future // dry work (M10 null test), but it does not affect this mapping. FX scoping is diff --git a/src/render_settings.h b/src/render_settings.h index e331fe9..ab79da6 100644 --- a/src/render_settings.h +++ b/src/render_settings.h @@ -114,6 +114,20 @@ struct TailRenderSettings { // the Auto default or an explicit request (spec §Manual override). Pure + tested. TailRenderSettings tailRenderSettingsFor(TailMode mode, double manualTailMs); +// The REALTIME record-window end (in project seconds) a tail mode records to, given +// the request's exact range end (docs/product/capture-tail.md §The realtime path). +// Realtime does NOT drive RENDER_*; it records a generous window and trims later, so +// the window end is where the transport actually stops: +// None -> rangeEndSeconds (exact — no extra recording). +// Auto -> rangeEndSeconds + kMaxTailSeconds (the 8 s runaway cap; trimmed later). +// Manual -> rangeEndSeconds + clamp(manualTailMs, kMaxTailMs)/1000 (fixed, no trim). +// `manualTailMs` is used ONLY for Manual. Pure so the mode->window arithmetic (and +// the Manual clamp) is unit-tested outside the DAW; the backend applies the returned +// end to the record time selection. Shared -72 dB / 8 s constants are the same ones +// the offline tail uses (single source of truth). +double realtimeRecordWindowEnd(TailMode mode, double rangeEndSeconds, + double manualTailMs); + // The RENDER_SETTINGS value for a given source mode. `supported` is false only // for SourceMode::Realtime (that is the M8 backend, not offline render). struct RenderSettingsChoice { diff --git a/src/wav_trim.cpp b/src/wav_trim.cpp new file mode 100644 index 0000000..e8265ae --- /dev/null +++ b/src/wav_trim.cpp @@ -0,0 +1,160 @@ +// wav_trim — pure implementation. See wav_trim.h. NO REAPER / SWELL / vendor. + +#include "wav_trim.h" + +#include // std::memcpy, std::memcmp + +namespace reasampler { + +namespace { + +// Little-endian readers. Bounds are checked by the caller before each read; these +// assume `off + N <= bytes.size()`. memcpy avoids alignment/aliasing UB. +std::uint16_t readU16LE(const std::vector& b, std::size_t off) { + return static_cast(b[off] | (b[off + 1] << 8)); +} +std::uint32_t readU32LE(const std::vector& b, std::size_t off) { + return static_cast(b[off]) | + (static_cast(b[off + 1]) << 8) | + (static_cast(b[off + 2]) << 16) | + (static_cast(b[off + 3]) << 24); +} + +bool tagEquals(const std::vector& b, std::size_t off, const char* tag) { + return off + 4 <= b.size() && std::memcmp(b.data() + off, tag, 4) == 0; +} + +// WAVE format tags we accept as 32-bit float (see wav_trim.h FORMAT ASSUMPTION). +constexpr std::uint16_t kWaveFormatIeeeFloat = 0x0003; +constexpr std::uint16_t kWaveFormatExtensible = 0xFFFE; + +} // namespace + +WavLayout parseWavLayout(const std::vector& bytes) { + WavLayout out; + + // Minimum viable RIFF/WAVE: "RIFF"(4) size(4) "WAVE"(4) = 12 bytes. + if (bytes.size() < 12) return out; + if (!tagEquals(bytes, 0, "RIFF")) return out; + if (!tagEquals(bytes, 8, "WAVE")) return out; + + bool haveFmt = false; + std::uint16_t fmtTag = 0, channels = 0, bitsPerSample = 0; + std::uint32_t sampleRate = 0; + std::uint16_t extensibleSubFormatTag = 0; // set only when fmtTag == kWaveFormatExtensible + + // Walk the sub-chunks after "WAVE" (offset 12). Each is: id(4) size(4) body(size), + // body padded to an even byte count (RIFF word alignment). Stop cleanly if a + // header would run past the buffer — a malformed/truncated file is "invalid", + // never an OOB read. + std::size_t pos = 12; + while (pos + 8 <= bytes.size()) { + const std::size_t bodyOffset = pos + 8; + const std::uint32_t bodySize = readU32LE(bytes, pos + 4); + + if (tagEquals(bytes, pos, "fmt ")) { + // fmt body: at least 16 bytes (PCM/float common fields). + if (bodyOffset + 16 > bytes.size() || bodySize < 16) return out; + fmtTag = readU16LE(bytes, bodyOffset + 0); + channels = readU16LE(bytes, bodyOffset + 2); + sampleRate = readU32LE(bytes, bodyOffset + 4); + bitsPerSample = readU16LE(bytes, bodyOffset + 14); + // For WAVE_FORMAT_EXTENSIBLE (0xFFFE), read the SubFormat GUID's leading + // 2-byte tag at body offset 24 to distinguish float (0x0003) from PCM + // integer (0x0001) and all other sub-formats. Body must be >= 40 bytes to + // reach GUID offset 24 + 16 bytes of GUID, and the full GUID must fit in + // the buffer; otherwise we leave extensibleSubFormatTag at 0 (rejected). + if (fmtTag == kWaveFormatExtensible) { + if (bodySize >= 40 && bodyOffset + 40 <= bytes.size()) { + extensibleSubFormatTag = readU16LE(bytes, bodyOffset + 24); + } + } + haveFmt = true; + } else if (tagEquals(bytes, pos, "data")) { + // The data chunk: PCM starts at bodyOffset, declared length bodySize. + // Reject if it runs past the buffer (truncated / lying header). + if (bodyOffset + bodySize > bytes.size()) return out; + if (!haveFmt) return out; // data before fmt — not a WAV we parse + + // Plain IEEE-float tag (0x0003): accept as-is. + // Extensible tag (0xFFFE): accept only when the SubFormat tag read from + // the GUID at body offset 24 is also 0x0003 (IEEE float). SubFormat tag + // 0x0001 (PCM integer) or anything else with bitsPerSample==32 is NOT + // float and must be rejected to prevent mis-decoding as float. + const bool floatTag = (fmtTag == kWaveFormatIeeeFloat) || + (fmtTag == kWaveFormatExtensible && + extensibleSubFormatTag == kWaveFormatIeeeFloat); + if (!floatTag || bitsPerSample != 32 || channels == 0) return out; + + out.valid = true; + out.channelCount = channels; + out.sampleRate = sampleRate; + out.dataByteOffset = bodyOffset; + out.dataByteLength = bodySize; + out.riffSizeFieldOffset = 4; + out.dataSizeFieldOffset = pos + 4; // the `data` size field (LE uint32) + return out; + } + + // Advance past this chunk's body, honoring RIFF even-byte padding. Guard the + // additions against size_t overflow (a hostile bodySize near SIZE_MAX). + std::size_t advance = bodySize; + if (advance & 1u) ++advance; // pad byte + if (advance > bytes.size() - bodyOffset) break; // would overrun -> stop + pos = bodyOffset + advance; + } + + return out; // no data chunk found -> invalid +} + +std::vector extractFloatFrames(const std::vector& bytes, + const WavLayout& layout, + std::size_t startFrame, + std::size_t frameCount) { + std::vector out; + if (!layout.valid) return out; + + const std::size_t bytesPerFrame = + static_cast(layout.channelCount) * 4u; + const std::size_t totalFrames = layout.frameCount(); + if (startFrame >= totalFrames) return out; + + // Clamp the requested span to the frames that actually exist. + const std::size_t avail = totalFrames - startFrame; + const std::size_t frames = (frameCount < avail) ? frameCount : avail; + if (frames == 0) return out; + + const std::size_t firstByte = + layout.dataByteOffset + startFrame * bytesPerFrame; + out.resize(frames * layout.channelCount); + // memcpy each float (LE on target hosts — see header's byte-order note). + for (std::size_t i = 0; i < out.size(); ++i) { + float f = 0.0f; + std::memcpy(&f, bytes.data() + firstByte + i * 4u, 4u); + out[i] = f; + } + return out; +} + +WavTruncatePlan planWavTruncate(const WavLayout& layout, std::size_t keptFrames) { + WavTruncatePlan plan; + if (!layout.valid) return plan; + + const std::size_t totalFrames = layout.frameCount(); + if (keptFrames > totalFrames) return plan; // never grow + + const std::size_t bytesPerFrame = + static_cast(layout.channelCount) * 4u; + const std::size_t keptDataBytes = keptFrames * bytesPerFrame; + + plan.valid = true; + plan.newFileByteLength = layout.dataByteOffset + keptDataBytes; + plan.dataSizeFieldOffset = layout.dataSizeFieldOffset; + plan.newDataSize = static_cast(keptDataBytes); + plan.riffSizeFieldOffset = layout.riffSizeFieldOffset; + // RIFF size counts everything after the 8-byte "RIFF"+size prefix. + plan.newRiffSize = static_cast(plan.newFileByteLength - 8); + return plan; +} + +} // namespace reasampler diff --git a/src/wav_trim.h b/src/wav_trim.h new file mode 100644 index 0000000..7acf5ee --- /dev/null +++ b/src/wav_trim.h @@ -0,0 +1,101 @@ +#pragma once +// wav_trim — pure parse + truncate-plan for the realtime tail's PCM decay-scan trim. +// +// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO +// vendor/ includes. Standard library only. Builds and unit-tests without REAPER. +// +// WHY THIS EXISTS (docs/product/capture-tail.md §The realtime path). The realtime +// backend records a generous tail window, then trims the trailing decay by +// truncating the recorded WAV at a frame boundary. Truncating a WAV correctly is +// not "chop the bytes": the RIFF container's size fields (the top-level RIFF chunk +// size and the `data` sub-chunk size) must be patched to the kept byte count, or +// the file is a corrupt / mis-lengthed WAV. That header arithmetic — chunk walking, +// format verification, and the size-field patch offsets — is exactly the fiddly, +// easy-to-get-wrong logic the discipline unit-tests OUTSIDE the DAW. The REAPER +// shell (capture_realtime.cpp) does only the file I/O: read the bytes, call the +// pure parse, run the decay scan, call the pure plan, write the truncated bytes. +// +// FORMAT ASSUMPTION (flagged for DAW-verify). We record 32-bit float WAV +// (capture.cpp kRenderFormatWavFloat32; realtime records via REAPER's project +// record format, which the manual procedure sets to WAV/32-bit-float). This parser +// therefore verifies canonical PCM/IEEE-float WAV: a RIFF/WAVE container, a `fmt ` +// chunk declaring 32-bit float (format tag 3, or tag 0xFFFE WAVE_FORMAT_EXTENSIBLE +// with 32 bits), and a `data` chunk of interleaved little-endian float32. Anything +// else (a different depth, a non-WAV, a compressed source) is reported invalid and +// the shell SKIPS the trim (keeps the untrimmed window) rather than corrupting a +// file it does not understand. This is deliberately conservative. + +#include +#include +#include + +#include "peaks.h" // AudioSample (float) + +namespace reasampler { + +// The parsed geometry of a canonical 32-bit-float WAV. `valid` is false when the +// bytes are not a WAV we can safely trim (see FORMAT ASSUMPTION); every other field +// is meaningful only when valid. +struct WavLayout { + bool valid = false; + + std::uint16_t channelCount = 0; // from `fmt ` (the interleave stride) + std::uint32_t sampleRate = 0; // from `fmt ` (for frame<->seconds, if needed) + + // The `data` chunk: byte offset of its first PCM byte within the file, and its + // declared PCM byte length. frameCount = dataByteLength / (channelCount * 4). + std::size_t dataByteOffset = 0; + std::size_t dataByteLength = 0; + + // Byte offset of the two little-endian uint32 size fields the truncate patch + // rewrites: the top-level RIFF chunk size (bytes 4..7) and the `data` sub-chunk + // size (the 4 bytes immediately before dataByteOffset). + std::size_t riffSizeFieldOffset = 4; // always 4 for a RIFF file + std::size_t dataSizeFieldOffset = 0; + + std::size_t frameCount() const { + const std::size_t bytesPerFrame = static_cast(channelCount) * 4u; + return bytesPerFrame ? dataByteLength / bytesPerFrame : 0; + } +}; + +// Parses a WAV byte buffer's header geometry. Returns {valid=false} for anything +// that is not a canonical 32-bit-float RIFF/WAVE with a `fmt ` and a `data` chunk, +// or whose declared `data` length runs past the buffer. Does NOT copy PCM — it only +// locates it (extractFloatFrames does the copy). Pure + total (no throw, no UB). +WavLayout parseWavLayout(const std::vector& bytes); + +// Copies `frameCount` interleaved float frames starting at `startFrame` out of the +// WAV's `data` region into a flat [f0c0,f0c1,...] buffer (the shape peaks consumes). +// Clamps to the frames the buffer actually holds — never reads past `data`. Returns +// empty for an invalid layout or an out-of-range start. The floats are read +// little-endian via std::memcpy (no aliasing UB); on a big-endian host they would +// need a byte-swap — flagged, not handled, because the target (Windows/macOS/Linux +// on x86/ARM-LE) is little-endian and REAPER writes LE WAV. +std::vector extractFloatFrames(const std::vector& bytes, + const WavLayout& layout, + std::size_t startFrame, + std::size_t frameCount); + +// The plan to truncate a parsed WAV to `keptFrames` frames: the new total file byte +// length and the two size-field values to patch. `valid` is false if the layout is +// invalid or keptFrames exceeds the file's frames (never GROW a file — the caller +// clamps beforehand; this guards it too). +struct WavTruncatePlan { + bool valid = false; + + std::size_t newFileByteLength = 0; // truncate the file to exactly this length + std::size_t dataSizeFieldOffset = 0; // where to write newDataSize (LE uint32) + std::uint32_t newDataSize = 0; // kept PCM byte length + std::size_t riffSizeFieldOffset = 4; // where to write newRiffSize (LE uint32) + std::uint32_t newRiffSize = 0; // newFileByteLength - 8 (RIFF size excludes + // the 8-byte "RIFF"+size prefix) +}; + +// Computes the truncate plan to keep exactly `keptFrames` frames of a parsed WAV. +// keptFrames == layout.frameCount() is a valid no-op plan (file unchanged). Pure + +// total. The shell applies it: patch the two size fields in the byte buffer, then +// truncate the file to newFileByteLength. +WavTruncatePlan planWavTruncate(const WavLayout& layout, std::size_t keptFrames); + +} // namespace reasampler diff --git a/tests/test_peaks.cpp b/tests/test_peaks.cpp index 6567eed..d3b10ab 100644 --- a/tests/test_peaks.cpp +++ b/tests/test_peaks.cpp @@ -278,6 +278,76 @@ static void testLargeBinCountOverflowGuard() { CHECK(env[0][7].min == 0.0f && env[0][7].max == 0.0f); } +// --- lastFrameAboveThreshold: the realtime tail's decay-scan boundary primitive -- + +// A mono decaying ramp: frame i has amplitude that falls linearly to zero. With a +// threshold set between two frames' levels, the last frame above it is deterministic. +static void testLastFrameDecayingRamp() { + // 10 mono frames, amplitude 1.0 - i*0.1: frame0=1.0 ... frame9=0.1. + std::vector buf(10); + for (std::size_t i = 0; i < 10; ++i) buf[i] = 1.0f - 0.1f * (float)i; + + // Threshold 0.35: frames 0..6 (levels 1.0..0.4) exceed it; frame 6 is the last + // (level 0.4 > 0.35), frame 7 (0.3) does not. Strict > semantics. + CHECK(lastFrameAboveThreshold(buf, 1, 10, 0.35f) == 6); + + // Threshold just under frame 9's level (0.1): the very last frame stays. + CHECK(lastFrameAboveThreshold(buf, 1, 10, 0.05f) == 9); + + // Threshold above the loudest frame: nothing survives. + CHECK(lastFrameAboveThreshold(buf, 1, 10, 1.5f) == kNoFrameAboveThreshold); +} + +// Pure silence at or below the threshold -> sentinel (the "trim back to end" case: +// no frame in the tail window exceeds -72 dB). +static void testLastFrameSilence() { + std::vector zeros(20, 0.0f); + CHECK(lastFrameAboveThreshold(zeros, 2, 10, 0.001f) == kNoFrameAboveThreshold); + + // A DC level exactly AT the threshold does not count (strict >). + std::vector atThresh(8, 0.25f); + CHECK(lastFrameAboveThreshold(atThresh, 1, 8, 0.25f) == kNoFrameAboveThreshold); +} + +// Every frame above the threshold (a non-decaying source): the last frame is the +// boundary — the caller keeps the whole window (the 8 s cap did its job). +static void testLastFrameAllAbove() { + std::vector loud(12, 0.8f); // 6 stereo frames + CHECK(lastFrameAboveThreshold(loud, 2, 6, 0.1f) == 5); +} + +// Per-frame peak is the MAX abs across channels (no fold): a frame with one loud +// channel and one silent channel is "above" on the strength of the loud one, and a +// negative sample is compared by magnitude. +static void testLastFramePerChannelMaxAbs() { + // 3 stereo frames. Frame0: (0.9, 0.0) loud L. Frame1: (0.0, -0.9) loud R (negative + // -> abs). Frame2: (0.05, -0.05) both quiet. + std::vector buf = {0.9f, 0.0f, 0.0f, -0.9f, 0.05f, -0.05f}; + // Threshold 0.5: frame2 is below (peak 0.05), frame1 is above (|-0.9|=0.9). + CHECK(lastFrameAboveThreshold(buf, 2, 3, 0.5f) == 1); + // If both channels of the last frame mattered independently, a fold-average + // (0.9+0.0)/2 = 0.45 on frame0 would fall below 0.5 — but frame0's L alone (0.9) + // is above, proving max-abs, not average. Lower the threshold to isolate frame0. + std::vector f0 = {0.9f, 0.0f}; + CHECK(lastFrameAboveThreshold(f0, 2, 1, 0.5f) == 0); +} + +// Degenerate: zero channels, zero frames, and a frameCount that overstates the +// buffer (must clamp to available frames, no OOB read). +static void testLastFrameDegenerate() { + std::vector buf = {0.5f, 0.5f, 0.5f, 0.5f}; // 2 stereo frames + + CHECK(lastFrameAboveThreshold(buf, 0, 2, 0.1f) == kNoFrameAboveThreshold); + CHECK(lastFrameAboveThreshold(buf, 2, 0, 0.1f) == kNoFrameAboveThreshold); + + std::vector empty; + CHECK(lastFrameAboveThreshold(empty, 2, 10, 0.1f) == kNoFrameAboveThreshold); + + // frameCount=100 but only 2 real stereo frames: clamps to frame 1 (the last real + // frame), which is above -> index 1, no read past the buffer. + CHECK(lastFrameAboveThreshold(buf, 2, 100, 0.1f) == 1); +} + int main() { testSineEnvelope(); testRampMonotonic(); @@ -289,6 +359,11 @@ int main() { testSingleBinWholeBuffer(); testDegenerateInputs(); testLargeBinCountOverflowGuard(); + testLastFrameDecayingRamp(); + testLastFrameSilence(); + testLastFrameAllAbove(); + testLastFramePerChannelMaxAbs(); + testLastFrameDegenerate(); if (g_fail == 0) std::printf("All tests passed.\n"); return g_fail ? 1 : 0; diff --git a/tests/test_render_settings.cpp b/tests/test_render_settings.cpp index a1727e7..57a30d8 100644 --- a/tests/test_render_settings.cpp +++ b/tests/test_render_settings.cpp @@ -126,6 +126,31 @@ static void testTailManualClampsToCap() { CHECK(tailRenderSettingsFor(TailMode::Manual, -50.0).tailMs == 0.0); } +// --- realtimeRecordWindowEnd: the T2 record-window extension ----------------- + +static void testRealtimeWindowNoneIsExact() { + // None -> the exact range end, no extra recording (byte-identical to today). + CHECK(realtimeRecordWindowEnd(TailMode::None, 12.5, 2000.0) == 12.5); + // manualTailMs is ignored for None. + CHECK(realtimeRecordWindowEnd(TailMode::None, 12.5, 0.0) == 12.5); +} + +static void testRealtimeWindowAutoAddsCap() { + // Auto -> range end + the 8 s runaway cap (trimmed later by the decay scan). + CHECK(realtimeRecordWindowEnd(TailMode::Auto, 10.0, 0.0) == 10.0 + kMaxTailSeconds); + // manualTailMs is ignored for Auto (the cap is fixed). + CHECK(realtimeRecordWindowEnd(TailMode::Auto, 10.0, 3000.0) == 10.0 + kMaxTailSeconds); +} + +static void testRealtimeWindowManualAddsClampedLength() { + // Manual -> range end + the set length in seconds (fixed, no trim). + CHECK(realtimeRecordWindowEnd(TailMode::Manual, 5.0, 2000.0) == 5.0 + 2.0); + // Clamped to the 8 s cap: > 8000 ms -> +8 s. + CHECK(realtimeRecordWindowEnd(TailMode::Manual, 5.0, 9000.0) == 5.0 + kMaxTailSeconds); + // Negative floors to 0 -> no extra window (never records before the range end). + CHECK(realtimeRecordWindowEnd(TailMode::Manual, 5.0, -100.0) == 5.0); +} + // --- parseRazorEdits: P_RAZOREDITS string -> ranges -------------------------- static void testParseSingleTrackAudioArea() { @@ -267,6 +292,9 @@ int main() { testAutoTrimRatioDerivesFromDb(); testTailManualFixedNoTrim(); testTailManualClampsToCap(); + testRealtimeWindowNoneIsExact(); + testRealtimeWindowAutoAddsCap(); + testRealtimeWindowManualAddsClampedLength(); testParseSingleTrackAudioArea(); testParseMultipleAreas(); testParseSkipsEnvelopeLaneAreas(); diff --git a/tests/test_wav_trim.cpp b/tests/test_wav_trim.cpp new file mode 100644 index 0000000..a615be2 --- /dev/null +++ b/tests/test_wav_trim.cpp @@ -0,0 +1,372 @@ +// Standalone tests for reasampler::wav_trim — no REAPER, no test framework. +// Builds synthetic 32-bit-float WAV byte buffers, asserts the parse geometry, the +// float extraction, and the truncate-plan arithmetic (the header size-field patch). +// +// Covers: canonical stereo/mono 32-bit-float parse; a leading unknown chunk skipped; +// format rejection (16-bit PCM, non-WAV, data-before-fmt, truncated data); frame +// extraction (whole / tail window / clamp / out-of-range); truncate plan (kept +#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) + +// --- Synthetic WAV builder --------------------------------------------------- + +static void putU16(std::vector& b, std::uint16_t v) { + b.push_back(static_cast(v & 0xFF)); + b.push_back(static_cast((v >> 8) & 0xFF)); +} +static void putU32(std::vector& b, std::uint32_t v) { + b.push_back(static_cast(v & 0xFF)); + b.push_back(static_cast((v >> 8) & 0xFF)); + b.push_back(static_cast((v >> 16) & 0xFF)); + b.push_back(static_cast((v >> 24) & 0xFF)); +} +static void putTag(std::vector& b, const char* t) { + for (int i = 0; i < 4; ++i) b.push_back(static_cast(t[i])); +} +static void putFloat(std::vector& b, float f) { + std::uint8_t tmp[4]; + std::memcpy(tmp, &f, 4); + for (int i = 0; i < 4; ++i) b.push_back(tmp[i]); +} + +// A canonical 32-bit-float WAV: RIFF/WAVE, fmt (tag 3, 16-byte body), data holding +// `frames` interleaved frames of `channels`. `leadingJunk` optionally inserts an +// unknown chunk before fmt to exercise the chunk walk. Samples: frame f, channel c +// = value(f,c). +template +static std::vector buildFloatWav(std::uint16_t channels, + std::uint32_t sampleRate, + std::size_t frames, + Fn value, + bool leadingJunk = false, + std::uint16_t fmtTag = 3, + std::uint16_t bits = 32) { + const std::uint32_t dataBytes = + static_cast(frames * channels * (bits / 8)); + + std::vector chunks; // everything after "WAVE" + if (leadingJunk) { + putTag(chunks, "LIST"); + putU32(chunks, 4); + putTag(chunks, "INFO"); // 4-byte body, even -> no pad + } + // fmt chunk (16-byte body). + putTag(chunks, "fmt "); + putU32(chunks, 16); + putU16(chunks, fmtTag); // format tag + putU16(chunks, channels); + putU32(chunks, sampleRate); + const std::uint32_t byteRate = sampleRate * channels * (bits / 8); + putU32(chunks, byteRate); + putU16(chunks, static_cast(channels * (bits / 8))); // block align + putU16(chunks, bits); + // data chunk. + putTag(chunks, "data"); + putU32(chunks, dataBytes); + for (std::size_t f = 0; f < frames; ++f) + for (std::uint16_t c = 0; c < channels; ++c) + putFloat(chunks, value(f, c)); + + std::vector wav; + putTag(wav, "RIFF"); + putU32(wav, static_cast(4 + chunks.size())); // "WAVE" + chunks + putTag(wav, "WAVE"); + wav.insert(wav.end(), chunks.begin(), chunks.end()); + return wav; +} + +// Builds a WAVE_FORMAT_EXTENSIBLE (0xFFFE) WAV with a 40-byte fmt body. +// `subFormatTag` is the 2-byte leading tag embedded in the SubFormat GUID: +// 0x0003 = IEEE float, 0x0001 = PCM integer (and any other value to exercise rejection). +// bitsPerSample and the PCM data are always 32-bit float bytes regardless of subFormatTag +// (we're testing that the parser correctly rejects/accepts based on the GUID, not the data). +template +static std::vector buildExtensibleWav(std::uint16_t channels, + std::uint32_t sampleRate, + std::size_t frames, + Fn value, + std::uint16_t subFormatTag) { + const std::uint32_t dataBytes = + static_cast(frames * channels * 4u); + + // WAVEFORMATEXTENSIBLE fmt body (40 bytes): + // [0..1] wFormatTag = 0xFFFE + // [2..3] nChannels + // [4..7] nSamplesPerSec + // [8..11] nAvgBytesPerSec + // [12..13] nBlockAlign + // [14..15] wBitsPerSample = 32 + // [16..17] cbSize = 22 (extension size beyond the 18-byte WAVEFORMATEX) + // [18..19] wValidBitsPerSample = 32 + // [20..23] dwChannelMask = 0 + // [24..39] SubFormat GUID: first 2 bytes = subFormatTag (LE), rest = standard + // KSDATAFORMAT_SUBTYPE base GUID {00000000-0000-0010-8000-00aa00389b71} + std::vector fmt; + putU16(fmt, 0xFFFE); // wFormatTag + putU16(fmt, channels); // nChannels + putU32(fmt, sampleRate); // nSamplesPerSec + putU32(fmt, sampleRate * channels * 4u); // nAvgBytesPerSec + putU16(fmt, static_cast(channels * 4)); // nBlockAlign + putU16(fmt, 32); // wBitsPerSample + putU16(fmt, 22); // cbSize + putU16(fmt, 32); // wValidBitsPerSample + putU32(fmt, 0); // dwChannelMask + // SubFormat GUID (16 bytes): [subFormatTag, 0x0000, 0x00, 0x00, 0x10, 0x00, + // 0x80, 0x00, 0x00, 0xaa, 0x00, 0x38, 0x9b, 0x71] + putU16(fmt, subFormatTag); // bytes [24..25]: the effective format tag + putU16(fmt, 0x0000); // bytes [26..27] + fmt.push_back(0x00); fmt.push_back(0x00); // bytes [28..29] + fmt.push_back(0x10); fmt.push_back(0x00); // bytes [30..31] + fmt.push_back(0x80); fmt.push_back(0x00); // bytes [32..33] + fmt.push_back(0x00); fmt.push_back(0xaa); // bytes [34..35] + fmt.push_back(0x00); fmt.push_back(0x38); // bytes [36..37] + fmt.push_back(0x9b); fmt.push_back(0x71); // bytes [38..39] + + std::vector chunks; + putTag(chunks, "fmt "); + putU32(chunks, static_cast(fmt.size())); // 40 + chunks.insert(chunks.end(), fmt.begin(), fmt.end()); + putTag(chunks, "data"); + putU32(chunks, dataBytes); + for (std::size_t f = 0; f < frames; ++f) + for (std::uint16_t c = 0; c < channels; ++c) + putFloat(chunks, value(f, c)); + + std::vector wav; + putTag(wav, "RIFF"); + putU32(wav, static_cast(4 + chunks.size())); + putTag(wav, "WAVE"); + wav.insert(wav.end(), chunks.begin(), chunks.end()); + return wav; +} + +// --- Parse tests ------------------------------------------------------------- + +static void testParseCanonicalStereo() { + auto wav = buildFloatWav(2, 48000, 5, + [](std::size_t f, std::uint16_t c) { + return static_cast(f) + 0.1f * c; + }); + WavLayout L = parseWavLayout(wav); + CHECK(L.valid); + CHECK(L.channelCount == 2); + CHECK(L.sampleRate == 48000); + CHECK(L.dataByteLength == 5 * 2 * 4); + CHECK(L.frameCount() == 5); + // data body sits after RIFF(12) + fmt(8 header + 16 body) + data(8 header) = 44. + CHECK(L.dataByteOffset == 44); + CHECK(L.dataSizeFieldOffset == 40); // the 4 bytes before dataByteOffset + CHECK(L.riffSizeFieldOffset == 4); +} + +static void testParseMonoAndLeadingChunk() { + // A leading LIST/INFO chunk before fmt must be skipped by the walk. + auto wav = buildFloatWav(1, 44100, 3, + [](std::size_t f, std::uint16_t) { + return static_cast(f); + }, + /*leadingJunk=*/true); + WavLayout L = parseWavLayout(wav); + CHECK(L.valid); + CHECK(L.channelCount == 1); + CHECK(L.frameCount() == 3); + // Data still parses correctly despite the leading chunk shifting its offset. + auto pcm = extractFloatFrames(wav, L, 0, 3); + CHECK(pcm.size() == 3); + CHECK(pcm[0] == 0.0f && pcm[1] == 1.0f && pcm[2] == 2.0f); +} + +static void testParseRejectsNon32BitAndNonWav() { + // 16-bit PCM (tag 1, bits 16) -> rejected. + auto pcm16 = buildFloatWav(2, 48000, 4, + [](std::size_t, std::uint16_t) { return 0.0f; }, + false, /*fmtTag=*/1, /*bits=*/16); + CHECK(!parseWavLayout(pcm16).valid); + + // Not a RIFF file. + std::vector junk = {'N','O','P','E', 0,0,0,0, 'W','A','V','E'}; + CHECK(!parseWavLayout(junk).valid); + + // Too short to hold even the RIFF header. + std::vector tiny = {'R','I','F','F'}; + CHECK(!parseWavLayout(tiny).valid); +} + +static void testParseRejectsLyingDataLength() { + // Build a valid WAV, then inflate the `data` size field so it claims more bytes + // than the buffer holds -> must be rejected (no OOB trust). + auto wav = buildFloatWav(2, 48000, 4, + [](std::size_t, std::uint16_t) { return 1.0f; }); + WavLayout good = parseWavLayout(wav); + CHECK(good.valid); + // Overwrite the data size field with a huge value. + wav[good.dataSizeFieldOffset + 0] = 0xFF; + wav[good.dataSizeFieldOffset + 1] = 0xFF; + wav[good.dataSizeFieldOffset + 2] = 0xFF; + wav[good.dataSizeFieldOffset + 3] = 0x7F; + CHECK(!parseWavLayout(wav).valid); +} + +// --- Extraction tests -------------------------------------------------------- + +static void testExtractTailWindow() { + // Stereo, 10 frames. Sample value encodes frame+channel so a mis-index is caught. + auto wav = buildFloatWav(2, 48000, 10, + [](std::size_t f, std::uint16_t c) { + return static_cast(f) * 10.0f + c; + }); + WavLayout L = parseWavLayout(wav); + CHECK(L.valid); + + // The "tail region" the realtime trim scans: frames 6..9 (start at frame 6). + auto tail = extractFloatFrames(wav, L, 6, 100 /*clamps*/); + CHECK(tail.size() == 4 * 2); // frames 6,7,8,9, 2 channels each + CHECK(tail[0] == 60.0f && tail[1] == 61.0f); // frame 6: L=60,R=61 + CHECK(tail[6] == 90.0f && tail[7] == 91.0f); // frame 9: L=90,R=91 + + // Out-of-range start -> empty. + CHECK(extractFloatFrames(wav, L, 10, 4).empty()); + CHECK(extractFloatFrames(wav, L, 99, 4).empty()); +} + +// --- Truncate-plan tests ----------------------------------------------------- + +static void testTruncatePlanKeepFewer() { + auto wav = buildFloatWav(2, 48000, 10, + [](std::size_t, std::uint16_t) { return 0.0f; }); + WavLayout L = parseWavLayout(wav); + CHECK(L.valid); + + // Keep 4 of 10 frames. + WavTruncatePlan p = planWavTruncate(L, 4); + CHECK(p.valid); + const std::size_t bpf = 2 * 4; // channels * 4 bytes + CHECK(p.newDataSize == 4 * bpf); // 32 bytes of PCM kept + CHECK(p.newFileByteLength == L.dataByteOffset + 4 * bpf); // 44 + 32 = 76 + CHECK(p.newRiffSize == p.newFileByteLength - 8); + CHECK(p.dataSizeFieldOffset == L.dataSizeFieldOffset); + CHECK(p.riffSizeFieldOffset == 4); + + // Applying the plan yields a buffer that re-parses to exactly 4 frames. + std::vector trimmed(wav.begin(), + wav.begin() + p.newFileByteLength); + // Patch the two size fields (what the shell does before truncating on disk). + auto writeU32 = [](std::vector& b, std::size_t off, std::uint32_t v) { + b[off + 0] = static_cast(v & 0xFF); + b[off + 1] = static_cast((v >> 8) & 0xFF); + b[off + 2] = static_cast((v >> 16) & 0xFF); + b[off + 3] = static_cast((v >> 24) & 0xFF); + }; + writeU32(trimmed, p.dataSizeFieldOffset, p.newDataSize); + writeU32(trimmed, p.riffSizeFieldOffset, p.newRiffSize); + + WavLayout L2 = parseWavLayout(trimmed); + CHECK(L2.valid); + CHECK(L2.frameCount() == 4); + CHECK(L2.dataByteLength == 4 * bpf); +} + +static void testTruncatePlanKeepAllIsNoOp() { + auto wav = buildFloatWav(1, 48000, 6, + [](std::size_t, std::uint16_t) { return 0.0f; }); + WavLayout L = parseWavLayout(wav); + WavTruncatePlan p = planWavTruncate(L, 6); // keep all + CHECK(p.valid); + CHECK(p.newFileByteLength == wav.size()); // unchanged + CHECK(p.newDataSize == L.dataByteLength); +} + +// --- Extensible format tests ------------------------------------------------- + +// A WAVE_FORMAT_EXTENSIBLE fmt with SubFormat tag 0x0001 (PCM integer) and +// bitsPerSample==32 must be REJECTED — it is 32-bit integer, not 32-bit float. +static void testExtensiblePcmIntegerRejected() { + auto wav = buildExtensibleWav(2, 48000, 4, + [](std::size_t, std::uint16_t) { return 0.0f; }, + /*subFormatTag=*/0x0001); // PCM integer + CHECK(!parseWavLayout(wav).valid); +} + +// A WAVE_FORMAT_EXTENSIBLE fmt with SubFormat tag 0x0003 (IEEE float) and +// bitsPerSample==32 must be ACCEPTED and parse + trim correctly. +static void testExtensibleFloatAccepted() { + auto wav = buildExtensibleWav(2, 48000, 5, + [](std::size_t f, std::uint16_t c) { + return static_cast(f) + 0.1f * c; + }, + /*subFormatTag=*/0x0003); // IEEE float + WavLayout L = parseWavLayout(wav); + CHECK(L.valid); + CHECK(L.channelCount == 2); + CHECK(L.sampleRate == 48000); + CHECK(L.frameCount() == 5); + + // Frame extraction works correctly. + auto pcm = extractFloatFrames(wav, L, 0, 2); + CHECK(pcm.size() == 4); + CHECK(pcm[0] == 0.0f); // frame 0, channel 0 + CHECK(pcm[1] == 0.1f); // frame 0, channel 1 + + // Truncate plan is valid and re-parses cleanly. + WavTruncatePlan p = planWavTruncate(L, 3); + CHECK(p.valid); + CHECK(p.newDataSize == 3 * 2 * 4u); + std::vector trimmed(wav.begin(), wav.begin() + p.newFileByteLength); + auto writeU32 = [](std::vector& b, std::size_t off, std::uint32_t v) { + b[off + 0] = static_cast(v & 0xFF); + b[off + 1] = static_cast((v >> 8) & 0xFF); + b[off + 2] = static_cast((v >> 16) & 0xFF); + b[off + 3] = static_cast((v >> 24) & 0xFF); + }; + writeU32(trimmed, p.dataSizeFieldOffset, p.newDataSize); + writeU32(trimmed, p.riffSizeFieldOffset, p.newRiffSize); + WavLayout L2 = parseWavLayout(trimmed); + CHECK(L2.valid); + CHECK(L2.frameCount() == 3); +} + +static void testTruncatePlanKeepZeroAndGrowRejected() { + auto wav = buildFloatWav(2, 48000, 5, + [](std::size_t, std::uint16_t) { return 0.0f; }); + WavLayout L = parseWavLayout(wav); + + WavTruncatePlan zero = planWavTruncate(L, 0); + CHECK(zero.valid); + CHECK(zero.newDataSize == 0); + CHECK(zero.newFileByteLength == L.dataByteOffset); // header only + + // keptFrames > total -> refused (never grow a file). + CHECK(!planWavTruncate(L, 6).valid); + + // Invalid layout -> invalid plan. + WavLayout bad; + CHECK(!planWavTruncate(bad, 0).valid); +} + +int main() { + testParseCanonicalStereo(); + testParseMonoAndLeadingChunk(); + testParseRejectsNon32BitAndNonWav(); + testParseRejectsLyingDataLength(); + testExtractTailWindow(); + testTruncatePlanKeepFewer(); + testTruncatePlanKeepAllIsNoOp(); + testTruncatePlanKeepZeroAndGrowRejected(); + testExtensiblePcmIntegerRejected(); + testExtensibleFloatAccepted(); + + if (g_fail == 0) std::printf("All tests passed.\n"); + return g_fail ? 1 : 0; +} From 4241ef4408f6e42c13dcf1e529053f1b0e81de2a Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Thu, 23 Jul 2026 18:30:43 -0400 Subject: [PATCH 04/22] docs: reflect D2-W2 landing in PLAN.md / COMPLETED.md Remove D2-W2 from PLAN.md; append completed entry to COMPLETED.md. Track-level auto-tag/park live; item-lane DAW verify deferred to post-D2-W3 (lane minting). D2-W3 left open. --- COMPLETED.md | 80 ++++++++++++++++++++++++++++++++++++++++++++++++++++ PLAN.md | 64 +---------------------------------------- 2 files changed, 81 insertions(+), 63 deletions(-) diff --git a/COMPLETED.md b/COMPLETED.md index c4a3b22..f2d3391 100644 --- a/COMPLETED.md +++ b/COMPLETED.md @@ -284,6 +284,51 @@ round-trip of the lane index. REAPER-free, unit-tested; mirror of D1. CONTEXT.md --- +## D2-W2 — shell: lane application + new-content detection +**Goal:** The view shell applies the planner's managed-lane ops in the DAW and the +bank_panel timer detects new content and auto-tags it to the active mode. Resolves +the two flagged implementation design points (I_FIXEDLANE reorder/renumber +fragility; the auto-tag / manual-lane detection heuristic). See CONTEXT.md +§Two-canvas sub-phase (Module architecture — shell; New-content detection). +**Verify (in DAW):** Toggling a mode shows + plays only the active mode's managed +lane, hides + silences the inactive-mode lane, and **never touches a manual lane** +(its `C_LANEPLAYS` stays exactly as the user set it); new content created while a +mode is active is tagged to that mode; pre-existing content stays Arrange (no +mass-tag on the first poll after open). +**Depends on:** D2-W1. + +- [x] Apply managed-lane ops in the view shell (`I_FREEMODE`/`I_FIXEDLANE`/ + `C_LANEPLAYS`/`B_FIXEDLANE_HIDDEN` via the item/track info setters; + `UpdateTimeline()` after `I_FREEMODE`); **managed lanes only, never manual**. + Verify every flag name/signature against the SDK header. +- [x] New-content detection on the bank_panel timer: diff the live track/item GUID + set against the previous poll; tag any GUID new since the last poll to the + then-active mode, with a **first-poll-after-open guard** (pre-existing ⇒ Arrange, + no mass-tag) and the **manual-lane exemption** (items in a manual lane not tagged). +- [x] Resolve the manual-lane detection heuristic (which new items are exempt) and + the `I_FIXEDLANE` lane-identity fragility (index survival across lane + reorder/renumber/deletion) — the two open design points from CONTEXT.md. +- [x] D2-W1 review polish: document the one-managed-lane-per-mode-per-track + exclusivity assumption in `laneModeState` (comment / debug-guard); clarify the + `serialize()` one-line style note; optional round-trip tests for the + last-writer-wins lane-replace contract. + +**Notes/decisions:** +- Two new pure modules added with unit tests: `guid_diff` (diffs live track/item + GUID sets between polls) and `lane_keys` (manages lane identity via durable + `P_LANENAME` rather than the renumber-prone `I_FIXEDLANE` ordinal, reconciled each + apply — the resolution to the lane-identity fragility design point). CTest green. +- **Manual-lane protection:** a single pure predicate `isOnManualLane` is the + exclusive gate; manual lanes — including REAPER's default unnamed fixed lanes — + are provably never driven or auto-tagged. +- **Track-level auto-tag and park behavior is live.** Item-lane show/hide is + correctly structured but is a provable no-op on real projects until D2-W3 mints + the `reasampler:`-prefixed named lanes. End-to-end DAW verification of item-lane + show/hide is sequenced after D2-W3 for this reason. +- W1 review polish was folded in during this wave. + +--- + ## Milestone 7 — capture action family **Goal:** Bindable capture actions for master / selected tracks / selected items / razor area, each with wet-dry + tail options. CONTEXT.md §actions, Build order 7. @@ -433,3 +478,38 @@ path. - **Follow-ons noted, not done:** Manual fine-adjust UI; per-project persistence of the toggle (currently extension-session lifetime, resets to None on unload); T2 realtime tail. + +--- + +## T2 — realtime tail (follow-on to T1) +**Goal:** The parallel tail path for the M8 realtime backend, which does not drive +`RENDER_*`: record an 8 s-capped tail window past the range end, then **trim in a +PCM decay-scan** to the -72 dB point (Manual = record fixed tail, skip the scan). +See `docs/product/capture-tail.md` §The realtime path. +**Verify (in DAW):** A realtime Auto capture of a decaying source records ≥ the range +then trims at the -72 dB decay point (± inherent realtime tolerance); realtime tail is +**not** asserted bit-identical (documented non-determinism). +**Depends on:** T1, M8. + +- [x] Record `[start, end + clamp(tail, 8 s)]` (extend the record time selection in + `capture_realtime.cpp`); Manual skips the scan, Auto proceeds to it. +- [x] Pure decay-scan helper alongside `peaks`: `lastFrameAboveThreshold(interleaved, + channels, frames, linearThreshold) -> frameIndex` (backward scan, per-frame max-abs + across channels, no fold); unit-tested with a synthetic decaying ramp. (Spec §realtime + path option (a) — recommended over bending `computeEnvelope`.) +- [x] Realtime shell: read the recorded wav PCM into a float buffer, find the trim + frame, rewrite the file truncated (new I/O the backend does not do today). + +**Notes/decisions:** +- New pure module `wav_trim` (`src/wav_trim.{h,cpp}`): 32-bit-float WAV parse + header-aware + truncate plan (RIFF/data size rewrite). Rejects WAVE_FORMAT_EXTENSIBLE with non-float + SubFormat GUID. Depends on `peaks` for the `AudioSample` float alias. Unit-tested via a + new `wav_trim_tests` CTest target. +- `peaks` gained `lastFrameAboveThreshold` (backward PCM scan, per-frame max-abs across + channels, no fold) for the Auto decay scan. +- **Auto/Manual/Off semantics.** Auto: records `[start, end + 8 s cap]`, scans backward + for the last frame above -72 dBFS, truncates the WAV header-aware at that frame. Manual: + records `[start, end + fixed tail]`, skips the scan. Off: byte-identical to the pre-tail + exact-bounds capture. +- **Realtime tail is non-deterministic by design** (inherent to the realtime backend). + Bit-identical repeats are not asserted for the realtime path; this is documented, not a defect. diff --git a/PLAN.md b/PLAN.md index f637adc..5325c0b 100644 --- a/PLAN.md +++ b/PLAN.md @@ -65,39 +65,6 @@ landed milestone. --- -# Milestone T — capture tail (rider on the offline render path) - -> **Rider, not a new pillar.** Tail preservation wires into the already-shipped -> offline `OfflineRenderBackend` (M3/M7) — no new backend, no new render trigger. -> It takes a **T** tag (not an M-number) because it is an enhancement to landed -> capture, sequenced independently of M8–M11. Authoritative spec: -> **`docs/product/capture-tail.md`** (full `RENDER_*` values, the surgical -> `RENDER_NORMALIZE`, the realtime parallel path, invariant interactions, -> acceptance criteria, DAW-confirm items). Parameters set by Daniel: auto-trim -> threshold **-72 dB**, max-tail cap **8 s**. When a point lands, doc-keeper moves -> it to `COMPLETED.md`. - -## T2 — realtime tail (follow-on to T1) -**Goal:** The parallel tail path for the M8 realtime backend, which does not drive -`RENDER_*`: record an 8 s-capped tail window past the range end, then **trim in a -PCM decay-scan** to the -72 dB point (Manual = record fixed tail, skip the scan). -See `docs/product/capture-tail.md` §The realtime path. -**Verify (in DAW):** A realtime Auto capture of a decaying source records ≥ the range -then trims at the -72 dB decay point (± inherent realtime tolerance); realtime tail is -**not** asserted bit-identical (documented non-determinism). -**Depends on:** T1, M8. - -- [ ] Record `[start, end + clamp(tail, 8 s)]` (extend the record time selection in - `capture_realtime.cpp`); Manual skips the scan, Auto proceeds to it. -- [ ] Pure decay-scan helper alongside `peaks`: `lastFrameAboveThreshold(interleaved, - channels, frames, linearThreshold) -> frameIndex` (backward scan, per-frame max-abs - across channels, no fold); unit-tested with a synthetic decaying ramp. (Spec §realtime - path option (a) — recommended over bending `computeEnvelope`.) -- [ ] Realtime shell: read the recorded wav PCM into a float buffer, find the trim - frame, rewrite the file truncated (new I/O the backend does not do today). - ---- - # Phase D2 — Two-canvas (item-level mode projection; additive to D1) > **Design View sub-phase.** Extends D1's track-level mode projection to **item @@ -111,36 +78,7 @@ then trims at the -72 dB decay point (± inherent realtime tolerance); realtime > spec. Product framing: `docs/product/design-view.md` §Two-canvas direction. When a > point lands, doc-keeper moves it to `COMPLETED.md`. > -> **D2-W1 (pure lane extension) has landed** — see `COMPLETED.md`. - -## D2-W2 — shell: lane application + new-content detection -**Goal:** The view shell applies the planner's managed-lane ops in the DAW and the -bank_panel timer detects new content and auto-tags it to the active mode. Resolves -the two flagged implementation design points (I_FIXEDLANE reorder/renumber -fragility; the auto-tag / manual-lane detection heuristic). See CONTEXT.md -§Two-canvas sub-phase (Module architecture — shell; New-content detection). -**Verify (in DAW):** Toggling a mode shows + plays only the active mode's managed -lane, hides + silences the inactive-mode lane, and **never touches a manual lane** -(its `C_LANEPLAYS` stays exactly as the user set it); new content created while a -mode is active is tagged to that mode; pre-existing content stays Arrange (no -mass-tag on the first poll after open). -**Depends on:** D2-W1. - -- [ ] Apply managed-lane ops in the view shell (`I_FREEMODE`/`I_FIXEDLANE`/ - `C_LANEPLAYS`/`B_FIXEDLANE_HIDDEN` via the item/track info setters; - `UpdateTimeline()` after `I_FREEMODE`); **managed lanes only, never manual**. - Verify every flag name/signature against the SDK header. -- [ ] New-content detection on the bank_panel timer: diff the live track/item GUID - set against the previous poll; tag any GUID new since the last poll to the - then-active mode, with a **first-poll-after-open guard** (pre-existing ⇒ Arrange, - no mass-tag) and the **manual-lane exemption** (items in a manual lane not tagged). -- [ ] Resolve the manual-lane detection heuristic (which new items are exempt) and - the `I_FIXEDLANE` lane-identity fragility (index survival across lane - reorder/renumber/deletion) — the two open design points from CONTEXT.md. -- [ ] D2-W1 review polish: document the one-managed-lane-per-mode-per-track - exclusivity assumption in `laneModeState` (comment / debug-guard); clarify the - `serialize()` one-line style note; optional round-trip tests for the - last-writer-wins lane-replace contract. +> **D2-W1 (pure lane extension) and D2-W2 (shell: lane application + new-content detection) have landed** — see `COMPLETED.md`. ## D2-W3 — actions, persist wiring, panel UI **Goal:** Any new lane/mode-management actions, the persist slice serializing the From 938c85a223473ac2bd1c01c6bcfe594062e16a7c Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Thu, 23 Jul 2026 18:33:12 -0400 Subject: [PATCH 05/22] docs: add wav_trim to CLAUDE.md targets/architecture; fix stale M8 note in PLAN Reflect the T2 wav_trim pure module and its test target. Correct the M10 null-test note: M8 realtime landed as a post-fader track tap, not pre-FX. --- CLAUDE.md | 4 +++- PLAN.md | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index f11d23e..6ca3b74 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,7 +22,7 @@ Vendors two submodules (see `.gitmodules`): cmake --build build ctest --test-dir build -Seven targets: +Key targets (see CMakeLists.txt for the full list): | Target | Kind | Purpose | |---|---|---| @@ -32,6 +32,7 @@ Seven targets: | `view_mode_model_tests` | executable | Pure unit tests for `view_mode_model` — no REAPER, no DAW. | | `view_tree_tests` | executable | Pure unit tests for `view_tree` — no REAPER, no DAW. | | `mode_switch_tests` | executable | Pure unit tests for `mode_switch` — no REAPER, no DAW. | +| `wav_trim_tests` | executable | Pure unit tests for `wav_trim` — no REAPER, no DAW. | | `reaper_reasampler` | loadable module | The actual extension binary (`.dll` / `.dylib` / `.so`). | ### macOS / Linux: SWELL dialog resources @@ -54,6 +55,7 @@ There is no hot-reload. Copy the built binary into REAPER's `UserPlugins/` folde - `view_mode_model` — Design View mode system: mode registry, GUID-keyed membership, folder-tree-aware visibility derivation, snapshot-based park/restore planner, JSON round-trip. Mirror of `bank_model` for the Design View phase. - `view_tree` — pure `I_FOLDERDEPTH`→FolderTree helper for the Design View shell; no REAPER types at the boundary. - `mode_switch` — REAPER-free segment layout + hit-test math for the bank_panel's Design View mode switch; divides a header rectangle into N equal segments and hit-tests a point to a segment. Mirror of `bank_grid`. +- `wav_trim` — 32-bit-float WAV parse + header-aware truncate plan (RIFF/data size rewrite) for the realtime tail's PCM decay-scan trim (T2). Rejects WAVE_FORMAT_EXTENSIBLE with non-float SubFormat GUID. Depends on `peaks` for the `AudioSample` float alias. **REAPER-facing shells:** - `capture` — `ICaptureBackend` interface; `OfflineRenderBackend` (deterministic default) and `RealtimeRecordBackend`. Input: `CaptureRequest`. Output: finished file + populated `Sample` handed to `bank_model`. diff --git a/PLAN.md b/PLAN.md index 5325c0b..3f8f5d4 100644 --- a/PLAN.md +++ b/PLAN.md @@ -37,7 +37,7 @@ tool's trust anchor and must pass. - [ ] Null-test verification action (capture → re-insert at source pos → assert silence sum). -**Note (from M7):** The null test requires a TRUE pre-FX dry capture, which REAPER offline render cannot produce via RENDER_SETTINGS (there is no pre-FX bit). True dry must be obtained by bypassing the source FX around an offline render (snapshot→bypass→render→restore) OR via the M8 realtime pre-FX path — so the dry-capture mechanism should be designed as part of the M10 null-test work. +**Note (from M7):** The null test requires a TRUE pre-FX dry capture, which REAPER offline render cannot produce via RENDER_SETTINGS (there is no pre-FX bit). True dry must be obtained by bypassing the source FX around an offline render (snapshot→bypass→render→restore) OR via the M8 realtime post-fader track-tap path — so the dry-capture mechanism should be designed as part of the M10 null-test work. ## Milestone 11 — polish **Goal:** Batch capture (per selected item / per razor area), From 37affc6a708176f5b791196ce147039b4045e793 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Thu, 23 Jul 2026 19:12:12 -0400 Subject: [PATCH 06/22] Add tail manual fine-adjust (scroll) + per-project tail persistence Relocate TailSetting into ReaSamplerSession; persist serializes it to a new forever-stable "tail_setting" ext-state key (load-per-project, save-on-save). Footer scroll-wheel adjusts Manual length in 250ms steps; label now shows "Tail: Manual X.Xs". Pure step/label/JSON round-trip covered by tests. --- src/bank_panel.cpp | 122 +++++++++++++++++++++++++++--------- src/persist.cpp | 29 +++++++++ src/persist.h | 22 +++++++ src/tail_control.cpp | 105 ++++++++++++++++++++++++++++++- src/tail_control.h | 31 +++++++-- tests/test_tail_control.cpp | 94 ++++++++++++++++++++++++++- 6 files changed, 365 insertions(+), 38 deletions(-) diff --git a/src/bank_panel.cpp b/src/bank_panel.cpp index 8fbaadf..d8f8688 100644 --- a/src/bank_panel.cpp +++ b/src/bank_panel.cpp @@ -77,6 +77,7 @@ #define REAPERAPI_WANT_DockWindowActivate #define REAPERAPI_WANT_DockWindowRemove #define REAPERAPI_WANT_EnumProjects +#define REAPERAPI_WANT_MarkProjectDirty // mark dirty when the tail toggle changes (saves with the project) #define REAPERAPI_WANT_GetMainHwnd #define REAPERAPI_WANT_PCM_Source_CreateFromFile #define REAPERAPI_WANT_PCM_Source_Destroy @@ -204,11 +205,13 @@ struct PanelState { // clear the selection rather than risk indices pointing past the new count. int selItemCount = 0; - // --- Tail-mode toggle (T1 exposure) --------------------------------------- - // The current tail setting the plain capture actions read (bankPanelTailSetting). - // Default None (exact bounds). In-memory only — resets on panel teardown; project - // persistence is a noted follow-on. Mutated ONLY by a click in the footer strip. - TailSetting tail; + // --- Tail-mode toggle ----------------------------------------------------- + // The authoritative tail setting now lives in ReaSamplerSession (session->tail()), + // NOT in panel state, so it travels inside the .rpp (persist serializes it on save, + // restores it on project load). The panel reads it for drawing and mutates it via + // the footer click (cycle mode) and scroll-wheel (Manual fine-adjust), marking the + // project dirty so the choice saves. bankPanelTailSetting is the read seam for the + // capture actions. Held here only through the session pointer above. // --- Audition preview (Wave B) -------------------------------------------- // @@ -492,9 +495,15 @@ RECT panelFooter(int w, int h) { return rc; } +// The session's live tail setting (default None / 2 s when no session). Single read +// point so draw, wheel-adjust, and the capture read seam all agree on the source. +TailSetting currentTail() { + return g_panel.session ? g_panel.session->tail() : TailSetting{}; +} + // Draws the tail-mode toggle into the footer strip: a filled band, a top divider, -// and the current mode's label ("Tail: Off / Auto / Manual") from the pure -// tail_control module. READ-ONLY: reads g_panel.tail; the click handler mutates it. +// and the current mode's label ("Tail: Off / Auto / Manual Xs") from the pure +// tail_control module. READ-ONLY: reads session->tail(); the input handlers mutate it. void drawTailFooter(LICE_IBitmap* bmp, int w, int h) { const RECT f = panelFooter(w, h); if (f.top >= f.bottom) return; // no room — skip (short panel) @@ -505,7 +514,7 @@ void drawTailFooter(LICE_IBitmap* bmp, int w, int h) { HDC dc = bmp->getDC(); if (!dc) return; - const std::string label = tailToggleLabel(g_panel.tail); + const std::string label = tailToggleLabel(currentTail()); RECT rc = f; rc.left += 8; // small left pad so the label is not flush against the edge SetTextColor(dc, kRgbFooterText); @@ -514,6 +523,26 @@ void drawTailFooter(LICE_IBitmap* bmp, int w, int h) { DT_LEFT | DT_VCENTER | DT_SINGLELINE | DT_END_ELLIPSIS); } +// True iff client-relative (x, y) falls inside the (non-degenerate) footer strip. +// Shared by the footer click (cycle mode) and the scroll-wheel (Manual fine-adjust) +// so both agree on the hit target. +bool pointInFooter(int x, int y) { + if (!g_panel.hwnd) return false; + RECT cr{}; + GetClientRect(g_panel.hwnd, &cr); + const RECT f = panelFooter(cr.right - cr.left, cr.bottom - cr.top); + return f.top < f.bottom && x >= f.left && x < f.right && y >= f.top && y < f.bottom; +} + +// Marks the active project dirty so a tail-setting change saves with the project. +// A bare tail toggle dirtying the project is intended (it IS project state now). +// persist writes the setting into ext state on the next save; this only flags that a +// save is owed. NON-DESTRUCTIVE: touches nothing in the bank/arrange. +void markTailDirty() { + ReaProject* proj = EnumProjects(-1, nullptr, 0); + if (proj) MarkProjectDirty(proj); +} + // The cell rects for the panel's CURRENT client width and bank size, translated // DOWN by the header height so the grid sits below the mode switch. Both paint and // mouse hit-testing call this so they share identical geometry (no drift between @@ -902,20 +931,17 @@ void handleClick(int x, int y) { } // Tail-mode footer: a click anywhere in the bottom strip cycles the tail mode - // (None -> Auto -> Manual -> None) and repaints. Settings-only — it mutates the - // panel's in-memory tail setting the capture actions read, and NOTHING in the - // project/bank/arrange. Checked before the grid so a footer click never selects. - { - RECT cr{}; - GetClientRect(g_panel.hwnd, &cr); - const int w = cr.right - cr.left; - const int h = cr.bottom - cr.top; - const RECT f = panelFooter(w, h); - if (f.top < f.bottom && x >= f.left && x < f.right && y >= f.top && y < f.bottom) { - g_panel.tail.mode = cycleTailMode(g_panel.tail.mode); - invalidatePanel(); - return; // footer click consumed; do NOT fall through to grid selection - } + // (None -> Auto -> Manual -> None) and repaints. It mutates the SESSION's tail + // setting (which the capture actions read and persist saves with the project) and + // marks the project dirty so the choice travels inside the .rpp — it touches + // NOTHING in the bank/arrange. Checked before the grid so a footer click never + // selects. + if (g_panel.session && pointInFooter(x, y)) { + TailSetting& tail = g_panel.session->tail(); + tail.mode = cycleTailMode(tail.mode); + markTailDirty(); + invalidatePanel(); + return; // footer click consumed; do NOT fall through to grid selection } const std::vector rects = panelRects(); @@ -938,6 +964,34 @@ void handleClick(int x, int y) { invalidatePanel(); } +// Handles a scroll-wheel notch over client (x, y) with signed wheel delta `delta`. +// Fine-adjusts the Manual tail length in kManualStepMs steps ONLY when the cursor is +// over the footer strip AND the mode is Manual — wheel up lengthens, down shortens, +// clamped to [0, kMaxTailMs]. In Off/Auto (or off the footer) it does nothing (returns +// false so the caller can let REAPER/the docker handle the wheel normally). On a real +// change it mutates the SESSION's tail setting, marks the project dirty (so it saves), +// and repaints the live length. Returns true iff the wheel was consumed. +bool handleWheel(int x, int y, int delta) { + if (!g_panel.session) return false; + if (!pointInFooter(x, y)) return false; + + TailSetting& tail = g_panel.session->tail(); + if (tail.mode != TailMode::Manual) return false; // fine-adjust is Manual-only + + // One notch is WHEEL_DELTA (120); accumulate whole notches so a high-res trackpad + // that sends fractional deltas still steps predictably. Sign carries direction. + const int notches = delta / 120; + if (notches == 0) return false; // sub-notch movement — nothing to apply yet + + const double before = tail.manualMs; + tail.manualMs = adjustManualMs(tail.manualMs, notches, kManualStepMs); + if (tail.manualMs == before) return true; // already at a bound — consumed, no change + + markTailDirty(); + invalidatePanel(); // label shows the new length live + return true; +} + // The column count for the panel's CURRENT client width (nav needs the same wrap // the layout uses). >= 1. int columnsNow() { @@ -1047,6 +1101,19 @@ WDL_DLGRET dlgProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) { handleClick(x, y); return 0; } + case WM_MOUSEWHEEL: { + // Fine-adjust the Manual tail length when the wheel is over the footer. + // UNLIKE the button messages, WM_MOUSEWHEEL carries SCREEN coordinates in + // lParam (Win32 and SWELL agree — swell-generic-gdk.cpp §WM_MOUSEWHEEL), so + // convert to client space before hit-testing the footer. The signed wheel + // delta is the HIWORD of wParam (SWELL packs it as (delta<<16), delta=+/-120, + // matching GET_WHEEL_DELTA_WPARAM). Consume (return 1) only when the footer + // handler acts, so scrolling elsewhere in the dock still behaves normally. + POINT pt{GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam)}; + ScreenToClient(hwnd, &pt); + const int delta = static_cast(HIWORD(wParam)); + return handleWheel(pt.x, pt.y, delta) ? 1 : 0; + } case WM_DESTROY: // REAPER closed the dock (user X'd it). Stop any audition (window-close // stop path — no preview may outlive the window) and reflect closed @@ -1156,12 +1223,11 @@ void bankPanelRefresh() { } TailSetting bankPanelTailSetting() { - // In-memory for the extension's lifetime (g_panel is static): the toggle's mode - // survives panel open/close and bank changes, and resets to the default None only - // on extension unload. Persistence across project reload is a noted follow-on. - // manualMs is clamped here so a caller always receives a within-cap length, even - // if a future fine-adjust UI stored an over-cap value. - TailSetting s = g_panel.tail; + // The authoritative setting lives in the session (session->tail()) so it travels + // inside the .rpp: it loads per project and saves with the project. This stays the + // read seam for the capture actions. manualMs is clamped here so a caller always + // receives a within-cap length regardless of what was stored/scrolled. + TailSetting s = currentTail(); s.manualMs = clampManualMs(s.manualMs); return s; } diff --git a/src/persist.cpp b/src/persist.cpp index e4c316e..c52922d 100644 --- a/src/persist.cpp +++ b/src/persist.cpp @@ -184,6 +184,13 @@ void ReaSamplerSession::saveToActiveProject() { SetProjExtState(static_cast(proj), kProjExtNamespace, kProjExtViewKey, viewJson.c_str()); + // Additive: the docked panel's tail setting rides alongside in its own key, so the + // tail choice travels inside the .rpp. Independent write — does not disturb the + // bank_index or view_state above. + const std::string tailJson = serializeTailSetting(tail_); + SetProjExtState(static_cast(proj), kProjExtNamespace, + kProjExtTailKey, tailJson.c_str()); + MarkProjectDirty(static_cast(proj)); } @@ -208,6 +215,23 @@ ViewModeModel loadViewModel(ReaProject* proj) { return std::move(*loaded); } +// Load the tail setting from a project's tail_setting key, or return the default. An +// absent/empty key (older / never-adjusted project) yields the default setting (None / +// 2 s manual) — graceful, never a crash. Malformed JSON is warned and also falls back +// to default, mirroring the bank's and view's malformed handling. +TailSetting loadTailSetting(ReaProject* proj) { + if (!proj) return TailSetting{}; + const std::string tailJson = + getProjExtStateString(proj, kProjExtNamespace, kProjExtTailKey); + if (tailJson.empty()) return TailSetting{}; // no stored setting -> default + std::optional loaded = deserializeTailSetting(tailJson); + if (!loaded) { + ShowConsoleMsg("ReaSampler: stored tail setting is malformed — ignoring.\n"); + return TailSetting{}; + } + return *loaded; +} + } // namespace void ReaSamplerSession::loadFromProject(void* proj, const std::string& projectDir) { @@ -225,6 +249,11 @@ void ReaSamplerSession::loadFromProject(void* proj, const std::string& projectDi // only — no visibility/processing is applied here (that is D4). view_ = loadViewModel(static_cast(proj)); + // The tail setting is restored on EVERY load path too (peer-symmetry): switching + // to a project with no stored setting must fall back to the default, not inherit + // the previous project's choice (this REPLACES the old session-carry behavior). + tail_ = loadTailSetting(static_cast(proj)); + if (!proj) { bank_ = BankIndex{}; return; diff --git a/src/persist.h b/src/persist.h index f00b271..3ee8f75 100644 --- a/src/persist.h +++ b/src/persist.h @@ -20,6 +20,7 @@ #include #include "bank_model.h" +#include "tail_control.h" #include "view_mode_model.h" namespace reasampler { @@ -38,6 +39,13 @@ inline constexpr const char* kProjExtIndexKey = "bank_index"; // FOREVER-STABLE: changing it orphans every already-saved project's view state. inline constexpr const char* kProjExtViewKey = "view_state"; +// The ext-state key the docked panel's TailSetting JSON (mode + manualMs) is stored +// under, so the tail choice travels inside the .rpp and loads per project. Distinct +// from the index/view keys — one namespace, three keys. FOREVER-STABLE: changing it +// orphans every already-saved project's tail setting (which then falls back to the +// default — graceful, but the user's saved choice would be lost). +inline constexpr const char* kProjExtTailKey = "tail_setting"; + // The ext-state key holding a GUID we mint per project to establish CONTENT-BASED // project identity (REAPER exposes no stable per-project GUID). poll() uses it to // tell a genuine Save-As (same GUID, new .rpp path) apart from a project switch @@ -80,6 +88,15 @@ public: ViewModeModel& view() { return view_; } const ViewModeModel& view() const { return view_; } + // The docked panel's tail setting (mode + manualMs), authoritative here — NOT in + // panel state — so it travels inside the .rpp: persist serializes it on save and + // replaces it on project load exactly as it treats the bank and view model. The + // panel reads/writes it through this seam (bank_panel holds the session), and the + // capture actions read it via bankPanelTailSetting. Default None / 2 s manual for + // an unsaved or pre-feature project (no stored key -> this default survives load). + TailSetting& tail() { return tail_; } + const TailSetting& tail() const { return tail_; } + // Serialize the current bank to the active project's ext state (namespace // "reasampler"). Non-destructive beyond writing our own ext-state key. Safe // to call when there is no active/saved project (it no-ops). @@ -110,6 +127,11 @@ private: // view_state (older project), so an absent key is graceful, not a crash. ViewModeModel view_; + // The tail setting. Default None / kDefaultManualTailMs; loadFromProject resets it + // to this default when a project has no stored tail_setting key (older / never- + // adjusted project), so an absent key is graceful. Peer to bank_/view_. + TailSetting tail_; + // The project identity last observed by poll(), used to detect load/Save-As. // The GUID is the PRIMARY signal (a different stored GUID = a different project // of record = Load, immune to pointer recycling). The pointer disambiguates the diff --git a/src/tail_control.cpp b/src/tail_control.cpp index 3fc2cbc..1d4063a 100644 --- a/src/tail_control.cpp +++ b/src/tail_control.cpp @@ -3,6 +3,10 @@ #include "tail_control.h" #include +#include +#include +#include +#include namespace reasampler { @@ -21,13 +25,108 @@ double clampManualMs(double manualMs) { return std::clamp(manualMs, 0.0, kMaxTailMs); } +double adjustManualMs(double current, int notches, double stepMs) { + // Clamp the stepped value so both scroll directions saturate at the bounds rather + // than running away (the same [0, kMaxTailMs] guard clampManualMs enforces). + return clampManualMs(current + notches * stepMs); +} + std::string tailToggleLabel(const TailSetting& setting) { switch (setting.mode) { - case TailMode::None: return "Tail: Off"; - case TailMode::Auto: return "Tail: Auto"; - case TailMode::Manual: return "Tail: Manual"; + case TailMode::None: return "Tail: Off"; + case TailMode::Auto: return "Tail: Auto"; + case TailMode::Manual: { + // Append the CLAMPED length in seconds to one decimal so the readout can + // never show an over-cap value even if manualMs was stored past the cap. + const double seconds = clampManualMs(setting.manualMs) / 1000.0; + char buf[32]; + std::snprintf(buf, sizeof(buf), "Tail: Manual %.1fs", seconds); + return std::string(buf); + } } return "Tail: Off"; // unreachable for a valid enum; fail to the safe default } +// --------------------------------------------------------------------------- +// JSON round-trip +// --------------------------------------------------------------------------- +// +// The setting is a flat object of one enum + one double, so a compact hand-rolled +// writer + a tolerant minimal reader is the simplest thing that works (mirroring +// bank_model's dependency-free JSON choice). manualMs is emitted with 17 significant +// digits (%.17g) — the shortest form that round-trips every IEEE-754 double exactly — +// so deserialize(serialize(x)) == x holds bit-for-bit. deserialize is deliberately +// forgiving: any parse failure returns nullopt so the caller falls back to a default, +// exactly as an absent ext-state key does. + +namespace { + +// The persisted integer for a mode. Stable forever (stored in the .rpp): never +// renumber these values or an already-saved project reads back the wrong mode. +int modeToInt(TailMode m) { + switch (m) { + case TailMode::None: return 0; + case TailMode::Auto: return 1; + case TailMode::Manual: return 2; + } + return 0; +} + +std::optional modeFromInt(int v) { + switch (v) { + case 0: return TailMode::None; + case 1: return TailMode::Auto; + case 2: return TailMode::Manual; + default: return std::nullopt; // unknown enumerant -> malformed -> default + } +} + +// Find the value token following `"key":` in `json`. Returns a pointer just past the +// colon (skipping whitespace) or nullptr if the key is absent. Minimal: the writer +// emits exactly one flat object with unique keys, so a substring search is sufficient +// and there is no nesting to confuse it. +const char* valueAfterKey(const std::string& json, const char* key) { + const std::string needle = std::string("\"") + key + "\""; + const std::size_t pos = json.find(needle); + if (pos == std::string::npos) return nullptr; + const char* p = json.c_str() + pos + needle.size(); + while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r') ++p; + if (*p != ':') return nullptr; + ++p; + while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r') ++p; + return p; +} + +} // namespace + +std::string serializeTailSetting(const TailSetting& setting) { + char buf[128]; + std::snprintf(buf, sizeof(buf), "{\"mode\":%d,\"manualMs\":%.17g}", + modeToInt(setting.mode), setting.manualMs); + return std::string(buf); +} + +std::optional deserializeTailSetting(const std::string& json) { + const char* modeTok = valueAfterKey(json, "mode"); + const char* msTok = valueAfterKey(json, "manualMs"); + if (!modeTok || !msTok) return std::nullopt; // absent key -> malformed -> default + + char* end = nullptr; + errno = 0; + const long modeVal = std::strtol(modeTok, &end, 10); + if (end == modeTok || errno != 0) return std::nullopt; + const std::optional mode = modeFromInt(static_cast(modeVal)); + if (!mode) return std::nullopt; + + end = nullptr; + errno = 0; + const double ms = std::strtod(msTok, &end); + if (end == msTok || errno != 0) return std::nullopt; + + TailSetting out; + out.mode = *mode; + out.manualMs = ms; + return out; +} + } // namespace reasampler diff --git a/src/tail_control.h b/src/tail_control.h index ba6cf2d..1e7ab93 100644 --- a/src/tail_control.h +++ b/src/tail_control.h @@ -9,6 +9,7 @@ // only (plus render_settings for the pure TailMode enum). Builds and unit-tests // without REAPER. +#include #include #include "render_settings.h" // TailMode (pure enum) — the three-state tail contract @@ -16,10 +17,15 @@ namespace reasampler { // The Manual-mode starting length. 2 s is a musically useful default tail (a bar of -// reverb throw at a moderate tempo) that is well under the 8 s cap. A fine-adjust UI -// (+/- click zones or scroll) is a noted follow-on; this pass ships a fixed default. +// reverb throw at a moderate tempo) that is well under the 8 s cap. Also the value a +// project with no stored tail setting (older / never-adjusted) falls back to on load. inline constexpr double kDefaultManualTailMs = 2000.0; +// The fine-adjust step per scroll-wheel notch in Manual mode. 250 ms is coarse enough +// that a few notches cover the useful range, fine enough to dial a length precisely. +// Daniel-set. The panel maps one wheel notch to +/- this many ms via adjustManualMs. +inline constexpr double kManualStepMs = 250.0; + // The panel's current tail setting: the mode plus the length used ONLY when the // mode is Manual. Held as in-memory panel/session state (bank_panel.cpp), default // None so a capture with no explicit choice stays exact-bounds / byte-identical to @@ -41,9 +47,24 @@ TailMode cycleTailMode(TailMode current); // tailMs into the CaptureRequest. Meaningful only for TailMode::Manual. double clampManualMs(double manualMs); -// The toggle's label for a setting, e.g. "Tail: Off", "Tail: Auto", "Tail: Manual". -// (Manual omits the length here — the panel is unobtrusive; a length readout can be -// added with the fine-adjust follow-on.) Pure so the exact strings are test-pinned. +// Applies `notches` scroll-wheel steps of `stepMs` each to `current`, clamped to +// [0, kMaxTailMs]. Positive notches lengthen, negative shorten. Pure so the fine-adjust +// arithmetic (and its clamp at both bounds) is unit-tested; the panel wheel handler +// owns no arithmetic of its own. Meaningful only for TailMode::Manual. +double adjustManualMs(double current, int notches, double stepMs); + +// The toggle's label for a setting, e.g. "Tail: Off", "Tail: Auto". In Manual mode the +// clamped length is appended in seconds to one decimal, e.g. "Tail: Manual 2.0s" — +// Off/Auto carry no length. Pure so the exact strings (and the Manual format) are +// test-pinned, including the boundary lengths (0.0s, 8.0s). std::string tailToggleLabel(const TailSetting& setting); +// JSON round-trip of a TailSetting (mode + manualMs), for persist to store the tail +// setting per-project alongside the bank and view model. Kept pure/testable here — +// the natural home, mirroring bank_model's serialize/deserialize. serialize emits a +// compact object; deserialize returns std::nullopt on malformed input so the caller +// (persist) falls back to a default setting, exactly as an absent key does. +std::string serializeTailSetting(const TailSetting& setting); +std::optional deserializeTailSetting(const std::string& json); + } // namespace reasampler diff --git a/tests/test_tail_control.cpp b/tests/test_tail_control.cpp index 632e8c7..09d20b0 100644 --- a/tests/test_tail_control.cpp +++ b/tests/test_tail_control.cpp @@ -49,15 +49,53 @@ static void testManualClampCapsAtEightSeconds() { CHECK(clampManualMs(-100.0) == 0.0); } +// --- adjustManualMs: the scroll-wheel fine-adjust arithmetic ------------------ + +static void testAdjustUpAndDownBySteps() { + // Positive notches lengthen, negative shorten, in whole kManualStepMs increments. + CHECK(adjustManualMs(2000.0, 1, kManualStepMs) == 2250.0); + CHECK(adjustManualMs(2000.0, -1, kManualStepMs) == 1750.0); + CHECK(adjustManualMs(2000.0, 4, kManualStepMs) == 3000.0); // 4 * 250 + CHECK(adjustManualMs(2000.0, 0, kManualStepMs) == 2000.0); // no notch, no move +} + +static void testAdjustClampsAtUpperBound() { + // Scrolling up past the 8 s cap saturates AT the cap, never beyond. + CHECK(adjustManualMs(kMaxTailMs, 1, kManualStepMs) == kMaxTailMs); + CHECK(adjustManualMs(kMaxTailMs - 100.0, 10, kManualStepMs) == kMaxTailMs); +} + +static void testAdjustClampsAtLowerBound() { + // Scrolling down past 0 floors at 0, never negative. + CHECK(adjustManualMs(0.0, -1, kManualStepMs) == 0.0); + CHECK(adjustManualMs(100.0, -10, kManualStepMs) == 0.0); +} + // --- tailToggleLabel: the exact strings the panel draws ----------------------- static void testLabelStringsPerMode() { TailSetting off; off.mode = TailMode::None; TailSetting autoM; autoM.mode = TailMode::Auto; - TailSetting man; man.mode = TailMode::Manual; + // Off/Auto carry NO length regardless of manualMs. + off.manualMs = 5000.0; + autoM.manualMs = 5000.0; CHECK(tailToggleLabel(off) == "Tail: Off"); CHECK(tailToggleLabel(autoM) == "Tail: Auto"); - CHECK(tailToggleLabel(man) == "Tail: Manual"); +} + +static void testManualLabelRendersLengthInSeconds() { + // Manual appends the length in seconds to one decimal — pin the format and the + // boundary values (0.0s, the 2 s default, the 8 s cap). + TailSetting man; man.mode = TailMode::Manual; + man.manualMs = 0.0; + CHECK(tailToggleLabel(man) == "Tail: Manual 0.0s"); + man.manualMs = kDefaultManualTailMs; // 2000 ms + CHECK(tailToggleLabel(man) == "Tail: Manual 2.0s"); + man.manualMs = kMaxTailMs; // 8000 ms + CHECK(tailToggleLabel(man) == "Tail: Manual 8.0s"); + // An over-cap stored value renders at the CLAMPED length, never past the cap. + man.manualMs = kMaxTailMs + 3000.0; + CHECK(tailToggleLabel(man) == "Tail: Manual 8.0s"); } static void testDefaultSettingIsOff() { @@ -69,13 +107,65 @@ static void testDefaultSettingIsOff() { CHECK(tailToggleLabel(s) == "Tail: Off"); } +// --- serialize/deserialize: per-project persistence round-trip ---------------- + +static bool settingsEqual(const TailSetting& a, const TailSetting& b) { + return a.mode == b.mode && a.manualMs == b.manualMs; +} + +static void testRoundTripNoneDefault() { + TailSetting s; // None + 2 s default + auto back = deserializeTailSetting(serializeTailSetting(s)); + CHECK(back.has_value()); + CHECK(back && settingsEqual(*back, s)); +} + +static void testRoundTripManualArbitraryMs() { + // A non-round manual length must round-trip bit-for-bit (17-sig-digit emit). + TailSetting s; s.mode = TailMode::Manual; s.manualMs = 3141.592653589793; + auto back = deserializeTailSetting(serializeTailSetting(s)); + CHECK(back.has_value()); + CHECK(back && settingsEqual(*back, s)); +} + +static void testRoundTripAuto() { + TailSetting s; s.mode = TailMode::Auto; s.manualMs = 500.0; + auto back = deserializeTailSetting(serializeTailSetting(s)); + CHECK(back.has_value()); + CHECK(back && settingsEqual(*back, s)); +} + +static void testDeserializeEmptyIsDefault() { + // An absent/empty stored value (older project) -> nullopt, so the caller falls + // back to the default. This is the graceful-old-project path the brief requires. + CHECK(!deserializeTailSetting("").has_value()); +} + +static void testDeserializeMalformedIsDefault() { + // Garbage, a missing key, or an unknown mode enumerant -> nullopt (no crash). + CHECK(!deserializeTailSetting("not json at all").has_value()); + CHECK(!deserializeTailSetting("{\"mode\":1}").has_value()); // manualMs missing + CHECK(!deserializeTailSetting("{\"manualMs\":2000}").has_value()); // mode missing + CHECK(!deserializeTailSetting("{\"mode\":9,\"manualMs\":2000}").has_value()); // bad enum + CHECK(!deserializeTailSetting("{\"mode\":x,\"manualMs\":2000}").has_value()); // non-numeric +} + int main() { testCycleOrderIsNoneAutoManualNone(); testCycleThreeStepsReturnsToStart(); testManualClampInRangeIsUnchanged(); testManualClampCapsAtEightSeconds(); + testAdjustUpAndDownBySteps(); + testAdjustClampsAtUpperBound(); + testAdjustClampsAtLowerBound(); testLabelStringsPerMode(); + testManualLabelRendersLengthInSeconds(); testDefaultSettingIsOff(); + testRoundTripNoneDefault(); + testRoundTripManualArbitraryMs(); + testRoundTripAuto(); + testDeserializeEmptyIsDefault(); + testDeserializeMalformedIsDefault(); if (g_fail == 0) std::printf("tail_control: all tests passed\n"); else std::printf("tail_control: %d CHECK(s) FAILED\n", g_fail); From beaec8642a11d692cd204fe0d649632cbd203aee Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Thu, 23 Jul 2026 19:19:10 -0400 Subject: [PATCH 07/22] fix: tail toggle/scroll now commits to ext-state immediately markTailDirty() called MarkProjectDirty() but never called saveToActiveProject(), so the new tail value was never written to SetProjExtState. On unsaved projects saveToActiveProject() no-ops cleanly, resolving the companion Major at the same time. --- src/bank_panel.cpp | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/bank_panel.cpp b/src/bank_panel.cpp index d8f8688..6f91d69 100644 --- a/src/bank_panel.cpp +++ b/src/bank_panel.cpp @@ -534,11 +534,15 @@ bool pointInFooter(int x, int y) { return f.top < f.bottom && x >= f.left && x < f.right && y >= f.top && y < f.bottom; } -// Marks the active project dirty so a tail-setting change saves with the project. -// A bare tail toggle dirtying the project is intended (it IS project state now). -// persist writes the setting into ext state on the next save; this only flags that a -// save is owed. NON-DESTRUCTIVE: touches nothing in the bank/arrange. +// Commits the current tail setting to ext state and marks the active project dirty +// so the change travels inside the .rpp on Ctrl+S. saveToActiveProject() is the only +// path that calls SetProjExtState for the tail key — calling it here closes the gap +// where toggle/scroll would dirty the project but the new value was never written. +// On an unsaved project saveToActiveProject() no-ops cleanly (documented in persist.h). +// MarkProjectDirty runs unconditionally so REAPER knows a save is owed either way. +// NON-DESTRUCTIVE: touches nothing in the bank/arrange. void markTailDirty() { + if (g_panel.session) g_panel.session->saveToActiveProject(); ReaProject* proj = EnumProjects(-1, nullptr, 0); if (proj) MarkProjectDirty(proj); } From 54d3ce39582de6200a0331203bd08a5747505360 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Thu, 23 Jul 2026 19:23:44 -0400 Subject: [PATCH 08/22] docs: archive tail follow-ons; add tail_setting key to persist doc Record the Manual scroll fine-adjust + per-project tail persistence in COMPLETED.md; name all three reasampler ext-state keys in CLAUDE.md. --- CLAUDE.md | 2 +- COMPLETED.md | 44 +++++++++++++++++++++++++++++++++++++++----- 2 files changed, 40 insertions(+), 6 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6ca3b74..e99808a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -61,7 +61,7 @@ There is no hot-reload. Copy the built binary into REAPER's `UserPlugins/` folde - `capture` — `ICaptureBackend` interface; `OfflineRenderBackend` (deterministic default) and `RealtimeRecordBackend`. Input: `CaptureRequest`. Output: finished file + populated `Sample` handed to `bank_model`. - `insert` — placement via `InsertMedia`; conform-to-project-tempo is an explicit opt-in flag, never silent stretching. - `bank_panel` — docked LICE-drawn grid: thumbnails, audition, multi-select, keyboard navigation. -- `persist` — project ext state (`SetProjExtState` / `GetProjExtState`, namespace `"reasampler"`) ↔ `bank_model` JSON + `ViewModeModel` JSON (`"view_state"` key); project-relative path resolution. +- `persist` — project ext state (`SetProjExtState` / `GetProjExtState`, namespace `"reasampler"`) ↔ `bank_model` JSON (`"bank_index"` key) + `ViewModeModel` JSON (`"view_state"` key) + `TailSetting` JSON (`"tail_setting"` key); project-relative path resolution. - `view` — Design View shell: reads the folder tree via `view_tree`, snapshots flag values before parking, drives hide + CPU-park on inactive-mode leaves (`B_SHOWINTCP`/`B_SHOWINMIXER`/`B_MAINSEND`/`I_FXEN` + per-FX offline) and derived visibility on parents; restores from snapshot. Never touches master or `B_MUTE`/`I_SOLO`. - `track_guid` — shared `MediaTrack*` → canonical GUID-string formatter; single source of truth for membership keys used by both the view shell and the actions layer. - `actions` — registers the capture/placement/slot action family and the Design View action family (toggle active mode, activate Arrange/Design, tag/untag selected tracks, show-both); routes each to the modules above via the `command_id`/`gaccel`/`hookcommand` contract. diff --git a/COMPLETED.md b/COMPLETED.md index f2d3391..f8041e7 100644 --- a/COMPLETED.md +++ b/COMPLETED.md @@ -473,11 +473,10 @@ path. - **`kNormalizeDisableAll = (4 << 16) = 262144`.** Used for None and Manual — the same disable-all value the pre-tail exact-bounds capture used. - **`tail_control` pure module** (`src/tail_control.{h,cpp}`): REAPER-free logic for - the panel toggle. `kDefaultManualTailMs = 2000.0` (2 s). Fine-adjust UI (± - click zones / scroll) is a noted follow-on; this pass ships a fixed default. -- **Follow-ons noted, not done:** Manual fine-adjust UI; per-project persistence of - the toggle (currently extension-session lifetime, resets to None on unload); T2 - realtime tail. + the panel toggle. `kDefaultManualTailMs = 2000.0` (2 s). Fine-adjust UI (scroll-wheel + in 250 ms steps) and per-project persistence landed as T1-followons (see below). +- **Follow-ons resolved:** Manual fine-adjust UI and per-project persistence of the + toggle landed as T1-followons. T2 realtime tail landed separately. --- @@ -513,3 +512,38 @@ then trims at the -72 dB decay point (± inherent realtime tolerance); realtime exact-bounds capture. - **Realtime tail is non-deterministic by design** (inherent to the realtime backend). Bit-identical repeats are not asserted for the realtime path; this is documented, not a defect. + +--- + +## T1-followons — Manual fine-adjust UI + per-project tail persistence +**Goal:** Close the two follow-ons deferred at T1 landing: (1) scroll-wheel fine-adjust +of the Manual tail length in the panel footer; (2) the tail setting (mode + Manual length) +persists per-project inside the `.rpp` rather than resetting on extension unload. +**Verify (in DAW):** Scroll-wheel over the footer adjusts Manual length in 250 ms steps, +clamped 0–8 s; the label reads "Tail: Manual X.Xs" (one decimal) in Manual mode; footer +click still cycles Off → Auto → Manual. The tail setting survives Save / close+reopen; +projects with no stored key fall back to Off / 2 s. +**Depends on:** T1. + +- [x] `adjustManualMs(current, notches, stepMs)` pure helper in `tail_control` (per-notch + ±`kManualStepMs` = 250 ms, clamped [0, `kMaxTailMs`]); unit-tested. +- [x] `tailToggleLabel` updated: Manual mode appends the clamped length in seconds to one + decimal, e.g. `"Tail: Manual 2.0s"`; unit-tested at boundary lengths. +- [x] Panel footer scroll-wheel handler calls `adjustManualMs` and repaints; click handler + unchanged (still cycles mode via `cycleTailMode`). +- [x] `serializeTailSetting` / `deserializeTailSetting` pure round-trip (mode + manualMs) + added to `tail_control`; unit-tested including `std::nullopt` on malformed input. +- [x] `TailSetting tail_` promoted into `ReaSamplerSession` (peer to `bank_` and `view_`); + `persist` serializes it under the forever-stable key `"tail_setting"` (namespace + `"reasampler"`) on save and reloads it on project open. Absent key → default Off / 2 s + (graceful for older/unsaved projects). +- [x] Changing the toggle marks the project dirty and commits the value to ext state; + `bankPanelTailSetting()` reads through the session (not a panel-local copy). + +**Notes/decisions:** +- `kManualStepMs = 250.0` — Daniel-set coarse-but-precise step; one wheel notch = ± 250 ms. +- Label format: `"Tail: Manual 2.0s"` (one decimal, `s` suffix) — format pinned by unit tests. +- Default fallback on absent/malformed key: `TailSetting { TailMode::None, kDefaultManualTailMs }` + (Off mode, 2 s stored length) — graceful for projects saved before this feature shipped. +- `kProjExtTailKey = "tail_setting"` is forever-stable (changing it would orphan saved choices, + falling back to the default — graceful but lossy). From 02b6350dc97e0a627b90701b85b5df5f1f8a7879 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Thu, 23 Jul 2026 19:26:06 -0400 Subject: [PATCH 09/22] fix(bank_panel): drive new-content baseline re-arm from persist's load signal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The detector re-armed its GuidBaseline on a pointer compare (proj != lastProject), a weaker signal than persist's GUID-primary identity. On a load onto a recycled ReaProject* the baseline never reset, so the just-loaded project's pre-existing tracks diffed against the previous project and were mass-tagged into the active mode — opening a Design-saved project mis-tagged its Arrange tracks. Re-arm now rides persist's authoritative load signal via bankPanelNotifyProjectLoaded(). --- src/bank_panel.cpp | 58 +++++++++++++++++++++++++++++----------- src/bank_panel.h | 11 ++++++++ src/main.cpp | 10 ++++++- tests/test_guid_diff.cpp | 57 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 120 insertions(+), 16 deletions(-) diff --git a/src/bank_panel.cpp b/src/bank_panel.cpp index 8fbaadf..3203d70 100644 --- a/src/bank_panel.cpp +++ b/src/bank_panel.cpp @@ -227,15 +227,26 @@ struct PanelState { // // 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). + // baseline carries the first-poll-after-open guard (GuidBaseline self-arms on its + // first observe()) so pre-existing content is never mass-tagged (it stays Arrange). + // + // Project-load re-arm is driven by persist's AUTHORITATIVE load lifecycle, NOT by a + // pointer compare here. main.cpp calls bankPanelNotifyProjectLoaded() on the exact + // tick persist restores a project's membership + active mode (the same tick it + // reapplies the active mode); that sets reloadPending so the NEXT detect tick this + // same tick re-baselines against the fully-loaded set and reports nothing new. This + // replaces the former `proj != lastProject` re-arm, which used a WEAKER signal than + // persist (pointer-only vs persist's GUID-primary identity) and so missed a load onto + // a RECYCLED ReaProject* address — the just-loaded project's pre-existing tracks then + // diffed against the previous project's stale baseline and were mass-tagged into the + // active mode (the reload-mis-tag bug). Coordinating with persist's signal makes the + // two identity checks agree by construction. + // + // Lives 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 + bool reloadPending = false; // set by bankPanelNotifyProjectLoaded; drained next detect tick }; PanelState g_panel; @@ -714,14 +725,19 @@ void detectNewContent() { 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) { + // A project (re)load re-arms the first-poll guard so we never diff across two + // projects. The signal is persist's — main.cpp calls bankPanelNotifyProjectLoaded() + // on the tick persist restores the project's membership + active mode, which sets + // reloadPending. Draining it here re-baselines against the fully-loaded set (that + // same tick's reapply-active-mode enumerated those tracks, so they are present), + // and the observe() below returns nothing new — pre-existing untagged tracks stay + // Arrange. GuidBaseline self-arms on its first observe() for the very first tick, so + // no separate first-tick handling is needed here. Using persist's GUID-primary load + // signal (not a local pointer compare) is what fixes the reload-mis-tag: the two + // identity checks can no longer diverge on a recycled ReaProject* address. + if (g_panel.reloadPending) { g_panel.contentBaseline.reset(); - g_panel.lastProject = proj; - g_panel.sawProject = true; + g_panel.reloadPending = false; } std::set live; @@ -1141,6 +1157,18 @@ std::vector bankPanelSelectedSampleIds() { return ids; } +void bankPanelNotifyProjectLoaded() { + // Persist restored a project's membership + active mode this tick (main.cpp calls + // this from the same consumeLoadSignal() branch that reapplies the active mode). + // Arm the new-content detector to re-baseline on its next tick so the just-loaded + // project's pre-existing content is treated as the baseline (nothing new) rather + // than diffed against the previous project and mass-tagged into the active mode. + // A flag (not an inline reset) because detectNewContent owns the baseline and runs + // later in the SAME OnTimer tick — it drains this and re-baselines against the live + // set in one place, keeping the reset and the observe() adjacent and ordered. + g_panel.reloadPending = true; +} + 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 diff --git a/src/bank_panel.h b/src/bank_panel.h index 6e38528..bec3670 100644 --- a/src/bank_panel.h +++ b/src/bank_panel.h @@ -50,6 +50,17 @@ std::vector bankPanelSelectedSampleIds(); // reflected without the panel diffing the bank itself. void bankPanelRefresh(); +// Notifies the panel that persist just (re)loaded a project's view model (membership + +// active mode). main.cpp calls this on the exact tick it drains persist's load signal +// and reapplies the active mode. It re-arms the new-content detector so the just-loaded +// project's PRE-EXISTING content is taken as the baseline (reported as nothing new), +// never diffed against the previously-open project and mass-tagged into the active mode. +// This coordinates the detector's project-identity signal with persist's authoritative +// (GUID-primary) one — the two can no longer diverge on a recycled ReaProject* address, +// which is what caused a project opened in Design to mis-tag its Arrange tracks. READ/ +// arm of panel state only; no project or bank mutation. +void bankPanelNotifyProjectLoaded(); + // The panel's current tail-mode setting (mode + Manual length), read by the plain // CAPTURE_ITEM / CAPTURE_TRACK actions when building a CaptureRequest so a capture // applies whatever the panel toggle is set to. Default None (exact bounds) — a diff --git a/src/main.cpp b/src/main.cpp index 5763147..f19d99c 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -216,8 +216,16 @@ static void OnTimer() // project saved in Design mode parks the Arrange tracks automatically, no manual // toggle. Fires exactly once per load (consumeLoadSignal clears it); idle ticks // skip it. proj = nullptr -> REAPER's active project (the one poll just loaded). - if (g_session.consumeLoadSignal()) + // + // The SAME signal re-arms the bank panel's new-content detector: a load must + // re-baseline the detector against the just-loaded project's content so its + // pre-existing tracks are never mis-detected as "new" and mass-tagged into the + // active mode (the reload-mis-tag bug). Notify BEFORE the reapply so the detector's + // re-arm and the model restore ride the one authoritative load event. + if (g_session.consumeLoadSignal()) { + reasampler::bankPanelNotifyProjectLoaded(); reasampler::applyMode(g_session.view(), g_session.view().activeModeId(), nullptr); + } // Reflect a live bank change (capture / project load) in the docked grid. // Cheap when the bank is unchanged (a fingerprint compare); repaints only on diff --git a/tests/test_guid_diff.cpp b/tests/test_guid_diff.cpp index 7c329e3..08febad 100644 --- a/tests/test_guid_diff.cpp +++ b/tests/test_guid_diff.cpp @@ -124,6 +124,61 @@ static void testResetReBaselines() { CHECK(p2new.size() == 1 && has(p2new, "{W}")); } +// -- 6. Reload-mis-tag regression: a project LOAD must re-baseline before the first +// post-load observe, so the newly-loaded project's PRE-EXISTING content is never +// reported as new. This locks the exact failure behind the reload-mis-tag bug: +// the detector used to re-arm on a `proj != lastProject` pointer compare, which a +// recycled ReaProject* address defeats; the previous project's stale baseline then +// reported the whole just-loaded project as new content and it got mass-tagged into +// the active mode. The fix routes the re-arm through persist's authoritative load +// signal (bankPanelNotifyProjectLoaded -> reset()), modeled here as: on a load, +// reset() runs BEFORE the first observe of the new project's set. +// +// The seam under test is GuidBaseline; the shell wiring (main.cpp notify -> +// bank_panel reset()) is DAW-verified, but the load-then-observe DECISION lives +// here and is what the bug got wrong. +static void testReloadReBaselinesBeforeFirstObserve() { + // Project A is open and settled: its content is the baseline, steady state reports + // nothing new. This is the "extension already running against project A" precondition + // the bug needs (a NON-empty stale baseline to mis-diff the next project against). + GuidBaseline b; + b.observe({"{A1}", "{A2}"}); // A baseline (first-poll guard) + CHECK(b.observe({"{A1}", "{A2}"}).empty()); // steady: nothing new + CHECK(b.primed()); + + // Daniel opens project B (saved in Design). B's pre-existing tracks are an ENTIRELY + // different GUID set from A. persist raises its load signal; the fix calls reset() + // (via bankPanelNotifyProjectLoaded) BEFORE the first post-load observe. + b.reset(); + auto afterLoad = b.observe({"{B1}", "{B2}", "{B3}"}); + // The load must tag NOTHING: B's pre-existing content is the baseline, not "new". + // Untagged/Arrange leaves stay Arrange; nothing is mass-tagged into Design. + CHECK(afterLoad.empty()); + + // And genuine post-load creation in B is still detected (the fix must not deafen the + // detector — only suppress the pre-existing set at the load boundary). + auto createdInB = b.observe({"{B1}", "{B2}", "{B3}", "{B4}"}); + CHECK(createdInB.size() == 1 && has(createdInB, "{B4}")); +} + +// -- 6b. Negative control: WITHOUT the load re-baseline (the old pointer-miss path where +// reset() never fired), the just-loaded project's pre-existing content IS reported +// as new — i.e. it would be mass-tagged. This proves the assertion in test 6 is +// load-bearing (the reset() is what prevents the mis-tag), not self-affirming. +static void testMissingReBaselineWouldMisTag() { + GuidBaseline b; + b.observe({"{A1}", "{A2}"}); // A baseline + b.observe({"{A1}", "{A2}"}); // settled against A + + // Simulate the BUG: no reset() on the load (the pointer compare missed a recycled + // ReaProject*). The next observe diffs B's set against A's stale baseline. + auto misdetected = b.observe({"{B1}", "{B2}", "{B3}"}); + // Every one of B's pre-existing tracks looks "new" — exactly the mass-tag that + // parked the Arrange tracks into Design on open. This is the failure the fix removes. + CHECK(misdetected.size() == 3); + CHECK(has(misdetected, "{B1}") && has(misdetected, "{B2}") && has(misdetected, "{B3}")); +} + int main() { testNewGuidsDifference(); testNewGuidsIgnoresEmpty(); @@ -131,6 +186,8 @@ int main() { testBaselineIncremental(); testBaselineDeletionReDetect(); testResetReBaselines(); + testReloadReBaselinesBeforeFirstObserve(); + testMissingReBaselineWouldMisTag(); if (g_fail == 0) std::printf("All tests passed.\n"); return g_fail ? 1 : 0; From 1743df135b882a577509395157be7a5a090bbfa0 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Thu, 23 Jul 2026 20:04:54 -0400 Subject: [PATCH 10/22] Reshape M10 to provenance-only, reconciled with dual-canvas Cut the null-test verify action and pre-FX-dry mechanism (Daniel's call); keep provenance + re-capture-from-source. Sample.provenance already exists/round-trips (M1), so M10 populates and consumes it. Add docs/product/provenance.md with dual-canvas forks P1-P4 and the leaned bank-only path; rewrite PLAN.md M10 as a proposal pending fork picks. --- PLAN.md | 52 ++++++-- docs/product/provenance.md | 243 +++++++++++++++++++++++++++++++++++++ 2 files changed, 282 insertions(+), 13 deletions(-) create mode 100644 docs/product/provenance.md diff --git a/PLAN.md b/PLAN.md index 3f8f5d4..4044378 100644 --- a/PLAN.md +++ b/PLAN.md @@ -23,21 +23,47 @@ state persists via the index. - [ ] Slot model + slot↔sample assignment. - [ ] "Capture to slot N" / "insert slot N" actions, MIDI-bindable. -## Milestone 10 — provenance + null-test verify action -**Goal:** Provenance (parent sample id + FX-chain snapshot) and "re-capture from -source"; ship the null-test verification action. CONTEXT.md §Precision invariants, -Build order 10. -**Verify (in DAW):** **Null test** — a dry offline capture of a range, re-inserted -at its source position, nulls to silence against the source. This action is the -tool's trust anchor and must pass. +## Milestone 10 — provenance (re-capture from source) +**Goal:** Populate `Sample.provenance` (parent sample id + a capture-recipe +fingerprint) on resample-from-sample, and ship a **"re-capture from source"** +action that regenerates a sample from its recorded source. Reconciled with the +dual-canvas (Phase D2) model. CONTEXT.md §Data model, §capture; product framing + +the reconciliation forks in `docs/product/provenance.md`. +**Verify (in DAW):** A sample resampled from a bank sample carries its parent id + +recipe fingerprint; "re-capture from source" regenerates the file into the bank +(never auto-inserting into the timeline — load-bearing principle); re-capture with +an unchanged source + request is byte-identical to the original (bit-identical +repeats); non-destructive to source items/tracks. -- [ ] Provenance fields populated on resample-from-sample (parent id + FX-chain - snapshot string). -- [ ] "Re-capture from source" action. -- [ ] Null-test verification action (capture → re-insert at source pos → assert - silence sum). +> **PROPOSAL — pending Daniel's fork picks (2026-07-23).** Reshaped from the old +> "provenance + null-test verify" M10. **Cut (fixed by Daniel):** the null-test +> verification *action* and the true-pre-FX-dry *mechanism* the old note required — +> both dropped, see `docs/product/provenance.md` §What was cut. **Kept:** provenance +> + re-capture. The `Sample.provenance` struct and its JSON round-trip **already +> exist** (M1) — M10 populates and consumes the field, it does not add it. The points +> below reflect the **leaned path** (bank-only re-capture, thin fingerprint); the +> re-place variant is deferred behind forks P2–P4. Lock the points once Daniel picks. -**Note (from M7):** The null test requires a TRUE pre-FX dry capture, which REAPER offline render cannot produce via RENDER_SETTINGS (there is no pre-FX bit). True dry must be obtained by bypassing the source FX around an offline render (snapshot→bypass→render→restore) OR via the M8 realtime post-fader track-tap path — so the dry-capture mechanism should be designed as part of the M10 null-test work. +- [ ] Populate `Sample.provenance` on resample-from-sample: `parentSampleId` (the + bank sample the capture derived from) + `fxChainSnapshot` as a **capture-recipe + fingerprint** (scope + source FX-chain identity at capture time — a drift/repro + fingerprint, NOT a pre-FX-dry chain; see product note fork P1). +- [ ] "Re-capture from source" action: regenerate a provenanced sample by re-running + its recorded capture request against the source's **current** state; update the + bank file + Sample in place. **Bank-only — never inserts/re-places into the + timeline** (load-bearing principle). Reports if the source drifted since capture. +- [ ] Verify: re-capture of an unchanged source is byte-identical to the original + capture (bit-identical repeats); non-destructive (`FxBypassGuard` snapshot/restore + as M7); relative-paths-only preserved. + +**Dual-canvas reconciliation (forks for Daniel — `docs/product/provenance.md`):** +Under the leaned path (bank-only re-capture) provenance is **pure per-sample bank +metadata**, `bank_model` and `view_mode_model` **stay decoupled**, and M10 touches +no canvas code — compliance is satisfied by staying on the right side of the +capture-never-places line. Forks P3 (canvas/lane memory in provenance) and P4 +(re-capture auto-tag interaction) only wake up if Daniel wants **P2 = +re-capture-and-replace-on-timeline**, which would make re-place a mode-aware +placement. Awaiting picks on P1 (fingerprint shape) and P2 (re-capture scope). ## Milestone 11 — polish **Goal:** Batch capture (per selected item / per razor area), diff --git a/docs/product/provenance.md b/docs/product/provenance.md new file mode 100644 index 0000000..b649b2b --- /dev/null +++ b/docs/product/provenance.md @@ -0,0 +1,243 @@ +# Provenance — product notes + +Framing, rationale, and the dual-canvas reconciliation behind the reshaped +**Milestone 10 (provenance)**. The tickable spec lives in `PLAN.md` (M10); the +authoritative technical detail is `CONTEXT.md` (§Data model, §capture) plus this +note for the reconciliation calls. This doc holds the *why* and the open forks so +they don't clutter the build docs. + +Status: **PROPOSAL, pending Daniel's sign-off (2026-07-23).** Reshaped from the +old "provenance + null-test verify" M10. Two decisions are fixed by Daniel and are +**not** reopened here (see *What was cut* below). The remaining content is the +provenance-only shape plus the dual-canvas interaction forks Daniel needs to +settle. + +--- + +## What was cut (fixed by Daniel — do not reopen) + +- **The null-test verification ACTION is cut.** Daniel verifies bit-accuracy + himself when he cares (he already null-tested the first capture spike, M3). The + tool ships no null-test button. +- **The "true pre-FX dry capture" mechanism is dropped.** The old M10 note said the + null test would require a true pre-FX dry render (bypass-around-render or the M8 + realtime pre-FX tap). With the null-test action gone, that mechanism has no + consumer and is dropped too. `CaptureRequest.wetDry` stays in the struct as an + inert seam (M7 already retained it), but M10 does **not** build a dry path. + +What survives from the old M10: **provenance + "re-capture from source,"** which +Daniel confirmed is genuinely useful — with the added constraint that it must be +coherent with the dual-canvas (Design View / two-canvas) architecture built in +parallel. + +--- + +## What provenance is (and what already exists) + +**Provenance records where a sample came from, so a sample can be regenerated from +its source.** The concrete case: you capture something into the bank, place it, +process it further, and re-capture the processed result — provenance is the thread +back from the child sample to the parent it was resampled from, plus enough about +the capture to reproduce it. + +**Most of the data model already exists.** `Sample` (M1, landed) already carries: + +``` +std::optional provenance; // set only when resampled +struct Provenance { + std::string parentSampleId; + std::string fxChainSnapshot; +}; +``` + +and it already JSON-round-trips (M1's lossless-round-trip test covers it). So M10 +is **not** "add provenance fields" — the seam is cut. M10 is: + +1. **Populate** `provenance` on captures that resample from an existing bank sample. +2. **Consume** it via a "re-capture from source" action that regenerates the sample. +3. **Reconcile** both with the dual-canvas model (the new work — see below). + +### What `fxChainSnapshot` should mean now (given the M7 rework) + +The old note assumed provenance would snapshot a chain for a *pre-FX dry* render. +That's gone. Under the **shipped M7 capture model**, capture is always wet and the +control is the **FX scope** (item = item/take FX only; track = item FX + that +track's own track FX), with the out-of-scope chain neutralized to unity by +`FxBypassGuard` for the render. There is no wet/dry dial. + +So `fxChainSnapshot` should record **the capture recipe, not a dry-render chain**: +the scope, the source range (already on `Sample.sourceRange`), the source track +GUID(s) (already on `Sample.trackGuids`), the tail setting, and — the genuinely new +bit — enough of the **source FX-chain identity at capture time** that "re-capture +from source" can tell whether the source still matches what was captured. This is a +*fingerprint for reproducibility*, not a mechanism for a different render mode. + +> **Fork P1 — how much chain state does `fxChainSnapshot` carry?** Two shapes: +> **(a) thin fingerprint** — a hash/summary of the source scope + FX-chain identity +> at capture time, used only to detect drift ("source has changed since capture") +> and to re-run the *same* capture request; or **(b) fat snapshot** — a full +> serialized FX-chain state string (`TrackFX` chunk) that re-capture could restore +> before rendering, so the regenerated sample matches even if the user has since +> tweaked the chain. (a) is simpler, non-destructive, and matches "re-capture +> reflects the source as it is *now*"; (b) is heavier, mutates the live chain during +> re-capture (a new destructive-ish surface), and re-opens some of the pre-FX-dry +> complexity we just cut. **Lean: (a) thin fingerprint.** Re-capture-from-source +> most naturally means "run the capture again against the source's *current* state" +> — that's the useful workflow (I changed the source, give me the updated sample). +> The fingerprint's job is to *tell* the user the source drifted, not to freeze it. +> Confirm this is the intent before scoping. + +--- + +## The dual-canvas reconciliation (the real new work) + +Daniel's constraint, verbatim: *"with the sound design canvas parallel, I think +provenance is genuinely useful, but we should make sure it's compliant with the +dual canvas stuff."* + +The dual-canvas ("two-canvas," Phase D2) architecture that landed in parallel: +Design View is a **mode projection** over one timeline reaching both **tracks** +(D1 parking) and **items** (D2 fixed lanes). New content is **auto-tagged to the +active mode** at creation. Membership + lane-ownership are GUID-keyed and persist +in the `"reasampler"` `view_state` section — a **separate** pure module +(`view_mode_model`) from the bank (`bank_model`). The settled placement rule +(CONTEXT §Capture placement — mode-aware): *an explicit placement while in Design +mode lands the item in the Design lane.* + +There are exactly **four** genuine interaction points between provenance and this +model. Each is a fork for Daniel. + +### Where re-capture lands (the load-bearing one) + +"Re-capture from source" regenerates a sample into the bank. Per the load-bearing +capture principle, **regenerating the bank sample never inserts into the timeline** +— so at the bank level there is *no* canvas interaction: the regenerated file + index +entry land in the bank exactly as any capture does, and the bank is mode-agnostic. + +The interaction only appears **if re-capture also re-places** the regenerated sample +onto the timeline (replacing the old placed item). That is a *placement*, and +placement is mode-aware under D2. + +> **Fork P2 — does "re-capture from source" re-place, or only refresh the bank +> entry?** Two shapes: +> **(a) bank-only re-capture** — regenerate the file + update the bank Sample +> in place; the user re-places manually if they want the new version on the +> timeline. Fully honors the load-bearing principle with zero canvas coupling; +> simplest; matches how every other capture behaves (capture ≠ placement). +> **(b) re-capture-and-replace** — regenerate *and* swap the placed timeline item +> for the new file. Convenient, but it is an auto-placement path, so it must obey +> the D2 mode-aware placement rule (lands in the active mode's lane / the original +> item's lane) and it touches the timeline (undo block, non-destructive to +> everything else). **Lean: (a) bank-only.** It keeps M10 inside the capture +> pillar's clean "capture never places" line and defers all the canvas-placement +> complexity. (b) can be a later opt-in ("re-capture and replace in place") once (a) +> proves the provenance thread. If Daniel wants (b), P3 and P4 below become live. + +### Does provenance need to record canvas/mode membership? + +`Sample` (bank) and `Membership`/`LaneOwnership` (view model) are **separate pure +modules today, by design** — the bank is mode-agnostic (a sample is just a file + +metadata; it doesn't know it was placed in Design). The question is whether +provenance must break that separation to record *which canvas/lane* the source item +lived in, so re-capture can put the regenerated sample back there. + +Under Fork P2 = (a) bank-only, the answer is **no** — re-capture doesn't place, so +it needs no canvas memory; the bank stays mode-agnostic and the two pure modules +stay decoupled. Under P2 = (b) re-place, the answer becomes **yes, partially**. + +> **Fork P3 — (only live if P2 = re-place) does provenance store canvas/lane +> membership?** If re-capture re-places, where does it land? +> **(a) active-mode rule** — re-placement follows the *same* D2 auto-tag/placement +> rule as any explicit placement: it lands in whatever mode is active *now*. +> Provenance stores **nothing** about canvas; the view model's existing rule +> governs. Keeps `bank_model` mode-agnostic. +> **(b) origin-lane memory** — provenance records the source item's mode/lane at +> capture time (a GUID + mode-id or lane-key) so re-capture lands the regenerated +> sample back in the *original* canvas regardless of the active mode. More faithful +> to "put it back where it was," but it couples `bank_model` provenance to +> `view_mode_model` identifiers — a cross-module reach the architecture currently +> avoids. **Lean: (a) active-mode rule**, kept in the view model; provenance stays +> pure bank metadata with no view-model ids. Only reach for (b) if "re-capture +> restores the exact original lane" is a stated requirement. + +### Re-capture and auto-tagging + +D2 auto-tags **new** track/item GUIDs (detected by the panel-timer GUID diff) +to the active mode. A re-placed item (P2 = b) is a *new* item GUID on the timeline, +so it would be auto-tagged to the active mode automatically — which is exactly Fork +P3 = (a) behavior, for free, via the existing detection path. No special-casing +needed *unless* Daniel wants origin-lane memory (P3 = b), in which case re-capture +must tag the new item explicitly and **suppress** the auto-tag for that GUID (or the +two fight). + +> **Fork P4 — (only live if P2 = re-place AND P3 = origin-lane) does re-capture +> preserve or re-run auto-tagging?** If provenance restores the origin lane, the +> re-placed item must be tagged to the *origin* mode, not the active mode — so +> re-capture has to write the membership itself and exempt that GUID from the +> timer's auto-tag (same class as the manual-lane exemption already in +> `autoTagNewContent`). **This fork only exists under P2=(b) + P3=(b).** Under the +> leaned defaults (P2=a, or P2=b + P3=a) it does not arise: bank-only re-capture +> places nothing, and active-mode re-placement rides the existing auto-tag path +> unchanged. + +### Summary of the leaned path + +If Daniel takes the leans (**P1=a thin fingerprint, P2=a bank-only re-capture**), +the reconciliation collapses to almost nothing: provenance is **pure per-sample +bank metadata**, `bank_model` and `view_mode_model` **stay decoupled**, and M10 +touches **no** canvas code. The dual-canvas compliance is satisfied by *staying on +the right side of the load-bearing line* (capture/re-capture never places), not by +new coupling. P3 and P4 only wake up if Daniel wants re-capture to also re-place +onto the timeline. + +This is the recommendation: **keep provenance in the bank, keep re-capture a +bank-only regenerate, and let the existing D2 placement rule handle the timeline if +and when the user manually re-places.** It is the smallest thing that delivers the +useful workflow and the cleanest against the architecture. + +--- + +## Persistence / precision-invariant implications (spec-level) + +- **Provenance is already-persisted metadata.** `Sample.provenance` already + serializes in the `BankIndex` JSON (M1) under the existing `bank_index` / + (post-Phase-B) `banks` ext-state key. **Populating it adds no new persistence + surface** — the round-trip test already exercises the field. The only spec note: + if Fork P1 grows `fxChainSnapshot` from a thin string to a fat FX-chunk (P1=b), + the field is still one string on `Sample`, so the JSON shape is unchanged, but the + blob gets heavier — a size consideration, not a schema one. Under P1=a (thin + fingerprint) the field stays small. +- **Under the leaned path, provenance touches `view_state` not at all.** No + lane-ownership or membership data is added for provenance; the view section is + unchanged. (Only P3=b would add view-model ids into provenance — and that would be + the argument *against* P3=b.) +- **Precision invariants are unaffected.** Re-capture is a capture: it produces a + file deterministically (bit-identical repeats hold — a re-capture with an + unchanged source and request is byte-identical to the original capture, which is + itself a nice provenance property), it is non-destructive to source items/tracks + (`FxBypassGuard` snapshot/restore, as M7), it honors exact bounds, and it writes + only relative paths. **Do not design the serialization here** — this is spec-level; + the implementer owns the JSON encoding of whatever P1 shape Daniel picks. +- **Do not reintroduce the dry path.** The precision-invariant list in CONTEXT still + names the null test as the "trust anchor." With the action cut, that line is now + historical framing, not an M10 deliverable — flag for doc-keeper to reconcile when + M10 lands, but M10 does **not** ship a null-test action or a pre-FX dry render. + +--- + +## Open forks for Daniel (the decision list) + +1. **P1 — `fxChainSnapshot` shape:** thin reproducibility fingerprint *(lean)* vs. + fat serialized FX-chain chunk. +2. **P2 — re-capture scope:** bank-only regenerate *(lean)* vs. + re-capture-and-replace-on-timeline. +3. **P3 — (only if P2=replace) canvas memory:** re-place follows the active-mode + rule, provenance stores no view ids *(lean)* vs. provenance records origin + mode/lane and restores it. +4. **P4 — (only if P2=replace AND P3=origin-lane) auto-tag interaction:** re-capture + writes origin-mode membership and exempts the GUID from the timer auto-tag. Only + live under the two non-leaned choices above. + +The leaned path (1a, 2a) makes 3 and 4 moot and keeps M10 a small, decoupled, +capture-pillar milestone. Awaiting Daniel's picks before the PLAN M10 points are +locked. From b182146f9a6e28624db708eb0a10b6417cb2d787 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Thu, 23 Jul 2026 20:08:38 -0400 Subject: [PATCH 11/22] 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(); From 92827c59be43f88116eab366917acc2ebf8ed7a5 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Thu, 23 Jul 2026 20:10:54 -0400 Subject: [PATCH 12/22] Lock M10 provenance spec to Daniel's fork picks (P1=a, P2=a) Settle the reshaped Milestone 10: thin capture-recipe fingerprint and bank-only re-capture; P3/P4 closed as moot. Remove the pending-picks banner in PLAN.md and stamp the provenance product note as settled. --- PLAN.md | 40 +++++++++--------- docs/product/provenance.md | 83 +++++++++++++++++++++----------------- 2 files changed, 66 insertions(+), 57 deletions(-) diff --git a/PLAN.md b/PLAN.md index 4044378..6e2960b 100644 --- a/PLAN.md +++ b/PLAN.md @@ -28,26 +28,26 @@ state persists via the index. fingerprint) on resample-from-sample, and ship a **"re-capture from source"** action that regenerates a sample from its recorded source. Reconciled with the dual-canvas (Phase D2) model. CONTEXT.md §Data model, §capture; product framing + -the reconciliation forks in `docs/product/provenance.md`. +the settled reconciliation in `docs/product/provenance.md`. **Verify (in DAW):** A sample resampled from a bank sample carries its parent id + recipe fingerprint; "re-capture from source" regenerates the file into the bank (never auto-inserting into the timeline — load-bearing principle); re-capture with an unchanged source + request is byte-identical to the original (bit-identical repeats); non-destructive to source items/tracks. -> **PROPOSAL — pending Daniel's fork picks (2026-07-23).** Reshaped from the old -> "provenance + null-test verify" M10. **Cut (fixed by Daniel):** the null-test -> verification *action* and the true-pre-FX-dry *mechanism* the old note required — -> both dropped, see `docs/product/provenance.md` §What was cut. **Kept:** provenance -> + re-capture. The `Sample.provenance` struct and its JSON round-trip **already -> exist** (M1) — M10 populates and consumes the field, it does not add it. The points -> below reflect the **leaned path** (bank-only re-capture, thin fingerprint); the -> re-place variant is deferred behind forks P2–P4. Lock the points once Daniel picks. +> **Reshaped from the old "provenance + null-test verify" M10.** **Cut (fixed by +> Daniel):** the null-test verification *action* and the true-pre-FX-dry *mechanism* +> the old note required — both dropped, see `docs/product/provenance.md` §What was +> cut. **Kept:** provenance + re-capture. The `Sample.provenance` struct and its JSON +> round-trip **already exist** (M1) — M10 populates and consumes the field, it does +> not add it. Fork picks settled by Daniel (2026-07-23): **P1=a thin fingerprint, +> P2=a bank-only re-capture**; P3/P4 moot under P2=a. The points below are locked to +> that path. - [ ] Populate `Sample.provenance` on resample-from-sample: `parentSampleId` (the - bank sample the capture derived from) + `fxChainSnapshot` as a **capture-recipe - fingerprint** (scope + source FX-chain identity at capture time — a drift/repro - fingerprint, NOT a pre-FX-dry chain; see product note fork P1). + bank sample the capture derived from) + `fxChainSnapshot` as a **thin capture-recipe + fingerprint** (scope + source FX-chain identity/hash at capture time — a drift/repro + fingerprint, NOT a serialized pre-FX-dry chain to restore; P1=a settled). - [ ] "Re-capture from source" action: regenerate a provenanced sample by re-running its recorded capture request against the source's **current** state; update the bank file + Sample in place. **Bank-only — never inserts/re-places into the @@ -56,14 +56,14 @@ repeats); non-destructive to source items/tracks. capture (bit-identical repeats); non-destructive (`FxBypassGuard` snapshot/restore as M7); relative-paths-only preserved. -**Dual-canvas reconciliation (forks for Daniel — `docs/product/provenance.md`):** -Under the leaned path (bank-only re-capture) provenance is **pure per-sample bank -metadata**, `bank_model` and `view_mode_model` **stay decoupled**, and M10 touches -no canvas code — compliance is satisfied by staying on the right side of the -capture-never-places line. Forks P3 (canvas/lane memory in provenance) and P4 -(re-capture auto-tag interaction) only wake up if Daniel wants **P2 = -re-capture-and-replace-on-timeline**, which would make re-place a mode-aware -placement. Awaiting picks on P1 (fingerprint shape) and P2 (re-capture scope). +**Dual-canvas reconciliation (settled — `docs/product/provenance.md`):** With +bank-only re-capture (P2=a), provenance is **pure per-sample bank metadata**, +`bank_model` and `view_mode_model` **stay decoupled**, and M10 touches **no** canvas +code. Dual-canvas compliance is satisfied by staying on the right side of the +capture-never-places line — not by any new coupling. Forks P3 (canvas/lane memory in +provenance) and P4 (re-capture auto-tag interaction) were only live under +re-capture-and-replace (P2=b) and are **closed as moot**; if the user manually +re-places a regenerated sample, the existing D2 mode-aware placement rule governs. ## Milestone 11 — polish **Goal:** Batch capture (per selected item / per razor area), diff --git a/docs/product/provenance.md b/docs/product/provenance.md index b649b2b..93eac29 100644 --- a/docs/product/provenance.md +++ b/docs/product/provenance.md @@ -6,11 +6,12 @@ authoritative technical detail is `CONTEXT.md` (§Data model, §capture) plus th note for the reconciliation calls. This doc holds the *why* and the open forks so they don't clutter the build docs. -Status: **PROPOSAL, pending Daniel's sign-off (2026-07-23).** Reshaped from the -old "provenance + null-test verify" M10. Two decisions are fixed by Daniel and are -**not** reopened here (see *What was cut* below). The remaining content is the -provenance-only shape plus the dual-canvas interaction forks Daniel needs to -settle. +Status: **SETTLED (2026-07-23).** Reshaped from the old "provenance + null-test +verify" M10. Two decisions were fixed by Daniel up front (see *What was cut* below). +The four dual-canvas interaction forks are now resolved: **P1=a thin fingerprint, +P2=a bank-only re-capture**, which makes **P3 and P4 moot (closed)**. The fork +analysis below is retained as the rationale record — each fork is stamped with its +resolution inline; nothing here is open. --- @@ -72,7 +73,8 @@ bit — enough of the **source FX-chain identity at capture time** that "re-capt from source" can tell whether the source still matches what was captured. This is a *fingerprint for reproducibility*, not a mechanism for a different render mode. -> **Fork P1 — how much chain state does `fxChainSnapshot` carry?** Two shapes: +> **Fork P1 — how much chain state does `fxChainSnapshot` carry? — CHOSEN: (a) +> thin fingerprint (Daniel, 2026-07-23).** Two shapes: > **(a) thin fingerprint** — a hash/summary of the source scope + FX-chain identity > at capture time, used only to detect drift ("source has changed since capture") > and to re-run the *same* capture request; or **(b) fat snapshot** — a full @@ -85,7 +87,8 @@ from source" can tell whether the source still matches what was captured. This i > most naturally means "run the capture again against the source's *current* state" > — that's the useful workflow (I changed the source, give me the updated sample). > The fingerprint's job is to *tell* the user the source drifted, not to freeze it. -> Confirm this is the intent before scoping. +> **Resolved 2026-07-23: (a). `fxChainSnapshot` is a capture-recipe fingerprint for +> drift detection and re-run — not a serialized chain to restore.** --- @@ -119,7 +122,7 @@ onto the timeline (replacing the old placed item). That is a *placement*, and placement is mode-aware under D2. > **Fork P2 — does "re-capture from source" re-place, or only refresh the bank -> entry?** Two shapes: +> entry? — CHOSEN: (a) bank-only re-capture (Daniel, 2026-07-23).** Two shapes: > **(a) bank-only re-capture** — regenerate the file + update the bank Sample > in place; the user re-places manually if they want the new version on the > timeline. Fully honors the load-bearing principle with zero canvas coupling; @@ -132,6 +135,9 @@ placement is mode-aware under D2. > pillar's clean "capture never places" line and defers all the canvas-placement > complexity. (b) can be a later opt-in ("re-capture and replace in place") once (a) > proves the provenance thread. If Daniel wants (b), P3 and P4 below become live. +> **Resolved 2026-07-23: (a). Re-capture regenerates the file into the bank and +> updates the Sample in place; it never places/replaces on the timeline. P3 and P4 +> are therefore moot — closed (b) can still be revisited as a later opt-in.** ### Does provenance need to record canvas/mode membership? @@ -146,7 +152,8 @@ it needs no canvas memory; the bank stays mode-agnostic and the two pure modules stay decoupled. Under P2 = (b) re-place, the answer becomes **yes, partially**. > **Fork P3 — (only live if P2 = re-place) does provenance store canvas/lane -> membership?** If re-capture re-places, where does it land? +> membership? — CLOSED/moot under P2=a (2026-07-23).** If re-capture re-places, where +> does it land? > **(a) active-mode rule** — re-placement follows the *same* D2 auto-tag/placement > rule as any explicit placement: it lands in whatever mode is active *now*. > Provenance stores **nothing** about canvas; the view model's existing rule @@ -159,6 +166,9 @@ stay decoupled. Under P2 = (b) re-place, the answer becomes **yes, partially**. > avoids. **Lean: (a) active-mode rule**, kept in the view model; provenance stays > pure bank metadata with no view-model ids. Only reach for (b) if "re-capture > restores the exact original lane" is a stated requirement. +> **Closed 2026-07-23: moot under P2=a. Re-capture never places, so it stores no +> canvas memory; provenance stays pure bank metadata and the two modules stay +> decoupled. Revisit only if re-capture-and-replace (P2=b) is later adopted.** ### Re-capture and auto-tagging @@ -171,29 +181,31 @@ must tag the new item explicitly and **suppress** the auto-tag for that GUID (or two fight). > **Fork P4 — (only live if P2 = re-place AND P3 = origin-lane) does re-capture -> preserve or re-run auto-tagging?** If provenance restores the origin lane, the +> preserve or re-run auto-tagging? — CLOSED/moot under P2=a (2026-07-23).** If +> provenance restores the origin lane, the > re-placed item must be tagged to the *origin* mode, not the active mode — so > re-capture has to write the membership itself and exempt that GUID from the > timer's auto-tag (same class as the manual-lane exemption already in > `autoTagNewContent`). **This fork only exists under P2=(b) + P3=(b).** Under the > leaned defaults (P2=a, or P2=b + P3=a) it does not arise: bank-only re-capture > places nothing, and active-mode re-placement rides the existing auto-tag path -> unchanged. +> unchanged. **Closed 2026-07-23: moot under P2=a — bank-only re-capture places +> nothing, so no auto-tag interaction arises.** -### Summary of the leaned path +### Summary of the settled path -If Daniel takes the leans (**P1=a thin fingerprint, P2=a bank-only re-capture**), -the reconciliation collapses to almost nothing: provenance is **pure per-sample -bank metadata**, `bank_model` and `view_mode_model` **stay decoupled**, and M10 -touches **no** canvas code. The dual-canvas compliance is satisfied by *staying on -the right side of the load-bearing line* (capture/re-capture never places), not by -new coupling. P3 and P4 only wake up if Daniel wants re-capture to also re-place -onto the timeline. +Daniel took the leans (**P1=a thin fingerprint, P2=a bank-only re-capture**, +2026-07-23), so the reconciliation collapses to almost nothing: provenance is +**pure per-sample bank metadata**, `bank_model` and `view_mode_model` **stay +decoupled**, and M10 touches **no** canvas code. The dual-canvas compliance is +satisfied by *staying on the right side of the load-bearing line* (capture/re-capture +never places), not by new coupling. P3 and P4 are moot (closed) — they were only +live if re-capture also re-placed onto the timeline. -This is the recommendation: **keep provenance in the bank, keep re-capture a -bank-only regenerate, and let the existing D2 placement rule handle the timeline if -and when the user manually re-places.** It is the smallest thing that delivers the -useful workflow and the cleanest against the architecture. +The settled shape: **keep provenance in the bank, keep re-capture a bank-only +regenerate, and let the existing D2 placement rule handle the timeline if and when +the user manually re-places.** It is the smallest thing that delivers the useful +workflow and the cleanest against the architecture. --- @@ -225,19 +237,16 @@ useful workflow and the cleanest against the architecture. --- -## Open forks for Daniel (the decision list) +## Decision list (settled 2026-07-23) -1. **P1 — `fxChainSnapshot` shape:** thin reproducibility fingerprint *(lean)* vs. - fat serialized FX-chain chunk. -2. **P2 — re-capture scope:** bank-only regenerate *(lean)* vs. - re-capture-and-replace-on-timeline. -3. **P3 — (only if P2=replace) canvas memory:** re-place follows the active-mode - rule, provenance stores no view ids *(lean)* vs. provenance records origin - mode/lane and restores it. -4. **P4 — (only if P2=replace AND P3=origin-lane) auto-tag interaction:** re-capture - writes origin-mode membership and exempts the GUID from the timer auto-tag. Only - live under the two non-leaned choices above. +1. **P1 — `fxChainSnapshot` shape: CHOSEN (a) thin reproducibility fingerprint.** + (Rejected: (b) fat serialized FX-chain chunk.) +2. **P2 — re-capture scope: CHOSEN (a) bank-only regenerate.** (Rejected for now: + (b) re-capture-and-replace-on-timeline; may return as a later opt-in.) +3. **P3 — canvas memory: CLOSED/moot under P2=a.** Was only live under P2=b; lean + was (a) active-mode rule, provenance stores no view ids. +4. **P4 — auto-tag interaction: CLOSED/moot under P2=a.** Was only live under P2=b + + P3=b. -The leaned path (1a, 2a) makes 3 and 4 moot and keeps M10 a small, decoupled, -capture-pillar milestone. Awaiting Daniel's picks before the PLAN M10 points are -locked. +Picks 1a + 2a make P3 and P4 moot and keep M10 a small, decoupled, capture-pillar +milestone. The PLAN M10 points are locked to this path. From b9046b284213c9696273e3ee26b5f7f1845a3ca2 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Thu, 23 Jul 2026 20:18:01 -0400 Subject: [PATCH 13/22] Add M11 polish points: keybinding help labels + action trigger buttons Bank_panel labels surface each capture action's current key binding (kbd_getTextFromCmd, unbound-safe); clickable buttons fire the actions through the existing command-id contract. Pure/shell split noted per point. --- PLAN.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/PLAN.md b/PLAN.md index 6e2960b..4bccf45 100644 --- a/PLAN.md +++ b/PLAN.md @@ -76,6 +76,20 @@ invariants; drag-out places a valid file in the OS target. - [ ] Resample-and-mute-source. - [ ] Conform-on-insert (explicit). - [ ] Native OS drag-out (deferred final; `InsertMedia` path must already work). +- [ ] Keybinding help labels: in the docked bank_panel, surface the current key + binding for each capture/provenance action (e.g. "Capture Item → ") by + querying the SDK for the key bound to the action's command id + (`kbd_getTextFromCmd(cmd, SectionFromUniqueID(0))` — main section) and formatting + a reminder label. Unbound case degrades to the action name with a clear + "unbound"/"—" marker (empty/blank return handled explicitly). Split: label-text + formatting (binding string + fallback → label) is **pure/testable**; the SDK + binding query + label draw is bank_panel shell. +- [ ] Action trigger buttons: clickable bank_panel buttons that fire the capture and + provenance actions directly, routing through the **existing** command-id contract + (`Main_OnCommand`/`KBD_OnMainActionEx` with the registered command id — the same id + minted at `registerAction`), never re-implementing capture. Split: button + hit-testing/layout math is **pure/testable** (mirror of `mode_switch`/`bank_grid`); + draw + command dispatch is bank_panel shell. --- From b931dada0ac0cfa62e233b8411bf35edace1479e Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Thu, 23 Jul 2026 20:21:50 -0400 Subject: [PATCH 14/22] Scope sample-remove (B5) and prune (Phase R) into the plan Adds the two missing removal capabilities: sample-level remove (Phase B point B5, index-only) and the prune file-lifecycle path (new Phase R, the sole file-deleting op), resolving the dangling capture/prune reference. Adds CONTEXT.md spec, PLAN.md points, and a product note; surfaces forks R-A..R-E for decision. --- CONTEXT.md | 274 +++++++++++++++++++++++ PLAN.md | 158 ++++++++++++- docs/product/removal-and-prune.md | 354 ++++++++++++++++++++++++++++++ 3 files changed, 776 insertions(+), 10 deletions(-) create mode 100644 docs/product/removal-and-prune.md diff --git a/CONTEXT.md b/CONTEXT.md index 465d863..569c841 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -731,3 +731,277 @@ and the settled-decision prose above). One panel-polish detail remains open. - **Active-bank indicator placement (B4 polish)** — per-region headers vs. a single header readout vs. lit-tab treatment. The "visually unmistakable" requirement is settled (fork 4); only the placement is open. Panel-polish detail. + +--- + +# Sample removal — additive spec (Phase B, point B5) + +> **Additive section, part of the Multi-bank pillar.** The sample-level companion +> to move/copy/evacuate/delete-bank: a verb that **drops a `Sample`'s index entry** +> from a bank (or the pool). Index-only, non-destructive to the file — it sits on +> the same side of the index/file line as every other Phase B op. Product framing: +> `docs/product/removal-and-prune.md` §Sample-remove. Same verify discipline: +> **verify every REAPER API name/signature against the SDK header before use.** + +## What it is + +Move, copy, and evacuate all keep a sample *somewhere*; there was no verb to drop +a sample outright. **Sample-remove** is that verb: it removes one `Sample` entry +from one `BankIndex`. It exposes the `remove` primitive `bank_model`'s `BankIndex` +**already has** — B5 wires it to an action + a panel affordance, it does not add a +model capability. + +## Settled decisions (spec-level) + +- **Remove is index-only.** It removes the `Sample` from a `BankIndex` and mutates + only index + ext-state. No file is written, moved, or deleted; no timeline item + is touched. Identical non-destructive posture to move/copy/evacuate/delete-bank. +- **Remove can orphan a file — the same designed orphaned-until-prune state a + non-empty delete-bank produces.** When remove drops the *last* index reference to + a file (no other bank holds its hash), that file becomes an orphan on disk, + referenced by no bank, reclaimed later by **prune** (Phase R) — never by remove. + This is not a new hazard class; it is the existing "files persist until prune" + window, reached by a sample-level verb instead of a bank-level one. +- **Collapse-by-hash is unaffected.** Remove targets a specific entry in a specific + bank. Because cross-bank dedup is deliberately not enforced, removing a sample + from one bank leaves any same-hash entry in another bank intact — the same + coexistence copy relies on. +- **The pool's *contents* are removable; the pool *container* is not.** Pool + privileges (un-deletable, un-renamable, un-evacuable) govern the pool as a + container. Individual samples **can** be removed from the pool — otherwise the + pool would be a one-way trap. Remove-from-pool is the pool's own "drop this + sample" verb and is allowed. +- **Remove scope (fork R-A, OPEN — needs Daniel).** Whether "remove" drops the + entry from *this* bank only (leaving copies elsewhere), purges the sample from + *every* bank, or offers both. Spec-of-record until settled: **remove-from-this- + bank** is the core verb (it is the model primitive and composes into + from-everywhere); design the action to carry a `scope: this-bank | all-banks` + parameter so from-everywhere is a later flag, not a rewrite. See product notes + §Fork R-A. + +## Precision / invariant implications + +- **Non-destructive** extends to remove verbatim: index + ext-state only, no file + touched, no timeline item touched. +- **Relative-paths-only** is unaffected — remove deletes an entry, it adds no path + handling. +- **Determinism / bit-identical / null-test (capture)** untouched — remove sits + above the file, same as all of multi-bank. + +## Guardrails + +- **Confirm on last-reference remove; don't confirm otherwise.** A remove that + drops the *last* index reference to a file orphans it (until prune) — confirm + that case, naming the consequence ("…its file remains on disk until pruned"). A + remove of a sample still referenced by another bank is cheap and re-derivable + (re-copy it back) and needs no confirmation. The confirmation is *earned by + actual orphan risk*, not fired on every remove. +- **Undo (fork R-B, OPEN — shared across all of Phase B).** REAPER's undo stack + does not cover `"reasampler"` ext-state mutations, so remove — like + move/copy/evacuate/delete-bank — is not on REAPER's Ctrl-Z as specced. The undo + model is a Phase-B-wide decision, not a remove-local one; see product notes + §Fork R-B. Surfaced here because remove is the first verb whose *only* effect is + index-entry destruction with no relocation, so it is where the gap first bites. + +## Module architecture (preserve the pure/shell split) + +- `bank_book` / `BankIndex` (pure) — expose remove of a `Sample` from a bank's + index (the existing `BankIndex::remove` primitive, surfaced through the book); + pool contents removable, pool-container privileges unchanged. +- `actions` (entry) — "remove selected sample(s) from bank" (and, under fork R-A, + a scope parameter); registered with the `command_id`/`gaccel`/`hookcommand` + contract; MIDI-bindable to suit the capture-heavy workflow. +- `bank_panel` (affordance) — remove on the current selection (menu entry / key), + reusing the M5 selection model exactly as move/copy do; confirm-on-last-reference + at this layer. + +## REAPER API surface + +No new REAPER API. Pure model + a new action command-id string under the sampler +family prefix + a panel affordance on the existing M5 LICE surface. Verify the +command-id/gaccel/hookcommand usage against `main.cpp` (unchanged contract). + +## Open questions + +- **Fork R-A — remove scope (this-bank | all-banks | both).** Open; needs Daniel. + Spec-of-record: this-bank primary with a scope-parameter seam. Product notes + §Fork R-A. +- **Fork R-B — undo model for index mutations.** Open; a Phase-B-wide decision + surfaced by remove. Product notes §Fork R-B. + +--- + +# Prune — file-lifecycle spec (Phase R — Reclaim) + +> **New pillar, its own lettered phase.** Prune is the file-lifecycle path the +> capture and multi-bank specs forward-reference throughout ("files persist on disk +> until prune", "the capture/prune path reclaims it") but that had no phase, module, +> or point until now. It is the **only** operation in ReaSampler that deletes bytes +> off disk. Namespaced **`R` (Reclaim)** alongside `M` (capture), `D` (Design View), +> `B` (Banks) — it is a distinct pillar, not a Multi-bank sub-step, because it +> serves *every* orphan-producing path (delete-bank, sample-remove, re-capture) and +> carries a new risk class (file deletion) with its own invariants. Product framing +> and the phase-placement justification: `docs/product/removal-and-prune.md` §Prune. +> Same discipline: **verify every REAPER/SWELL/filesystem API name/signature against +> the SDK/SWELL headers before use.** + +## What it is + +Over a project's life, delete-bank and sample-remove (and, potentially, M10 +re-capture superseding an old file) leave `.wav` files on disk that no bank index +references — the "orphaned-until-prune" state the specs design in on purpose. +**Prune is the reclaim pass**: reconcile the physical bank folder against the union +of every bank's index, and reclaim the files nothing references. It makes good on +the promise the rest of the spec keeps making. + +## The load-bearing rule + +> **Remove creates orphans; prune reclaims them.** Sample-remove and delete-bank +> drop index entries and may leave a file referenced by nothing. Prune is the +> single path that turns such an orphan back into free disk space. **No other +> operation deletes a file; prune deletes *only* files that no index references.** +> A bank op that deletes a file is still a bug — prune is not a bank op, it is the +> file-lifecycle op. + +This asymmetry is deliberate and must be stated loudly: every *other* invariant +says "no operation deletes a file." Prune is the sole, explicit exception, and its +entire job is deletion — so it must be the *only* file-deleting authority in the +system, with the strongest guardrails. + +## Mirror of `reconcile` — the pure pattern one level down + +Prune reuses the shape Design View already shipped. `view_mode_model`'s +`ViewModeModel::reconcile(liveGuids)` reconciles *membership entries* against *live +tracks* and returns the residuals to drop. **Prune reconciles *files on disk* +against *referenced files*** (the union of every bank's index) and returns the +orphan set to delete. Same pure pattern, one level down (files instead of GUIDs). + +The **decision is pure and unit-tested**: given the set of files present in the +bank folder and the set of files referenced by the book, compute the orphan set. +Only the two ends touch the shell — *enumerating* the bank folder and *deleting* +the orphans are filesystem I/O. Keep the "which files are orphans" core REAPER-free +and hard-tested (this is the safety-critical part); keep the I/O thin. Same +pure/shell split as `bank_model` / `view_mode_model` / `bank_book`. + +## Settled decisions (spec-level) + +- **Referenced-set is the union across ALL banks, pool included.** A file is an + orphan iff **no** bank in the book references it. Because copy lets one file be + referenced by several banks, prune must union references across the whole book + before deciding. This is the safety-critical computation — the **prune null + test** is *prune never deletes a file that any index references.* +- **Project-relative resolution, current folder.** Prune enumerates and deletes + within the project bank folder using the **same M4 project-relative path + resolution** the index uses, against the *resolved current* folder — never a + stale absolute path — so a Save-As relocation cannot cause it to mis-identify or + mis-target orphans. +- **Dry-run first, always.** Prune reports before it deletes: the orphan count, + reclaimed size, and (for a small set) the files. The dry-run — compute-and-report, + the pure core with no deletion — is the primary surface; actual deletion is the + confirmed second step. A prune that silently sweeps is unacceptable for an + irreversible file-delete. +- **Scope is the bank system's own leavings, not the folder at large.** Prune + reclaims files that *were* bank files and are now unreferenced — never a file a + user hand-dropped into the folder. How prune attributes ownership is fork R-D + (below). Prune is a reclaimer of ReaSampler's own orphans, not a general folder + cleaner. + +## Precision / invariant implications + +- **The single intentional exception to "no operation deletes files."** Stated + above; called out again here so the invariant table is honest: prune is + destructive-to-files *by design and by exclusive authority*. +- **Relative-paths-only / Save-As machinery reused** — prune resolves paths the + same way the index does (M4), so it inherits relative-path correctness and + Save-As survival; it introduces no new path handling. +- **Determinism / bit-identical / null-test (capture)** untouched — prune sits + below the capture path entirely. +- **Prune null test (new invariant):** a prune of a folder whose every file is + referenced by some bank deletes nothing; a prune deletes exactly the + `present − referenced` orphan set and nothing else. Ship as a tested property of + the pure core. + +## Guardrails — the genuinely destructive act + +- **Dry-run + confirm-with-manifest** (above): the user approves a *specific* + deletion (count + size + files), never an abstract "clean up." +- **Never a referenced file; never a non-bank file.** The union-across-all-banks + rule protects referenced files; the ownership-attribution rule (fork R-D) + protects hand-dropped files. +- **Safest platform deletion available (fork R-C, OPEN — needs Daniel + verify).** + Route deletions to the platform recycle bin / trash if a portable move-to-trash + is available (recoverable outside the app); fall back to unlink with the dry-run + + confirm guardrail if not. Default to the safest deletion the platform affords. +- **Manual, explicit trigger (fork R-E, OPEN).** Prune is a deliberate action the + user invokes, dry-run-first — never a silent background sweep. An optional "…and + prune now" convenience may be offered at the delete-bank confirmation; a periodic + background sweep is rejected (silent irreversible file-deletion violates the + guardrails). + +## Module architecture (preserve the pure/shell split) + +Pure (no REAPER types, unit-tested — the mirror of `reconcile`): +- **Prune-reconcile core** — given `{ files present in the bank folder }` and + `{ files referenced by the book }` (and, per fork R-D, `{ files the book owns }`), + compute the orphan set. REAPER-free, filesystem-free, unit-tested hard (the prune + null test lives here). The referenced-set is unioned across all banks by asking + the `bank_book`. + +REAPER-facing / filesystem-facing (thin): +- `persist` / session — supplies the referenced-set (union across the book) and, + per fork R-D, the owned-file manifest; resolves the current project bank folder + via the M4 project-relative machinery. +- A **prune shell** — enumerates the bank folder (filesystem I/O), feeds the + pure core, presents the dry-run manifest, and on confirmation deletes the orphan + set (via trash if available — fork R-C — else unlink). Filesystem I/O only; the + decision stays in the pure core. +- `actions` (entry) — "Prune bank folder" (dry-run-first, confirm-to-delete); + registered with the `command_id`/`gaccel`/`hookcommand` contract. + +## REAPER / platform API surface (verify all signatures) + +No new REAPER *audio* API. New surfaces to verify before use: +- **Filesystem enumeration + delete** — directory listing and file removal for the + project bank folder. **Verify** the portable approach against SWELL / the existing + file-handling in `persist` / `capture` (which already resolve and write files); + prefer reusing whatever path/file machinery M4 established. +- **Move-to-trash (fork R-C)** — if adopted, verify a portable move-to-trash + exists (SWELL, or per-platform: Win `IFileOperation`/`SHFileOperation`, macOS + `NSFileManager trashItemAtURL:`, Linux XDG trash spec). This is a **to-verify**, + not an assumed capability. +- **Persistence (fork R-D, if owned-file manifest adopted)** — a new tracked set in + the `"reasampler"` ext-state (or folded into the `banks` blob); shared M4 blob + machinery, new data only. +- **Actions** — the `command_id`/`gaccel`/`hookcommand` contract from `main.cpp` + (unchanged), a new command-id string under the sampler family prefix. + +## Non-goals / guardrails + +- **Prune is the ONLY file-deletion authority.** No bank op, no capture op, no + Design View op deletes a file. If any path other than prune deletes a bank file, + reject it in review. +- **No general folder cleaning.** Prune reclaims the bank system's own unreferenced + leavings, not arbitrary files a user placed in the folder (fork R-D governs the + attribution). +- **No silent deletion.** Dry-run + explicit confirm always; no background sweep. +- **No file deleted while any index references it.** The referenced-set union + across all banks is the safety-critical invariant — enforce and test it in the + pure core, not just the UI. +- **Additive only.** Prune reads the book and the folder; it does not modify + `BankIndex`, `bank_book`, the capture roadmap, or Design View semantics. + +## Open questions to resolve during build + +- **Fork R-C — deletion mechanism: OS trash vs. unlink.** Open; needs Daniel + a + platform to-verify. Lean: trash if portably available, else unlink with strong + dry-run/confirm. Product notes §Fork R-C. +- **Fork R-D — orphan attribution: owned-file manifest vs. index-diff vs. + folder-sweep.** Open; needs Daniel. Lean: **owned-file manifest**, and — because + the manifest is cheap to maintain from capture onward but a backfill cliff to + reconstruct later — **start tracking owned files as part of capture / Phase B + even though prune ships in Phase R** (defer the feature, design the seam). + Folder-sweep is rejected as unsafe (deletes hand-dropped files). Product notes + §Fork R-D. +- **Fork R-E — trigger: manual-only vs. offer-on-orphaning vs. periodic.** Open. + Lean: manual-primary (dry-run-first), an optional "…and prune now" at the + delete-bank confirmation, no background sweep. Product notes §Fork R-E. diff --git a/PLAN.md b/PLAN.md index 4bccf45..ba85b36 100644 --- a/PLAN.md +++ b/PLAN.md @@ -118,23 +118,34 @@ landed milestone. > spec. Product framing: `docs/product/design-view.md` §Two-canvas direction. When a > point lands, doc-keeper moves it to `COMPLETED.md`. > -> **D2-W1 (pure lane extension) and D2-W2 (shell: lane application + new-content detection) have landed** — see `COMPLETED.md`. +> **D2-W1 (pure lane extension), D2-W2 (shell: lane application + new-content +> detection), and D2-W3-A (lane minting + item→lane assignment + persist +> round-trip) have landed** — see `COMPLETED.md`. -## D2-W3 — actions, persist wiring, panel UI -**Goal:** Any new lane/mode-management actions, the persist slice serializing the -lane-ownership index alongside the membership index, and any panel UI indicator. +## D2-W3-B — two-canvas actions + panel UI indicators +**Goal:** Any new lane/mode-management actions (bindable in the Actions list), and +any panel UI indicator for lane/mode state. The persist slice and the +lane-ownership index round-trip were completed in D2-W3-A; this wave closes the +remaining UI and action surface. See CONTEXT.md §Two-canvas sub-phase (Module architecture — persistence). -**Verify (in DAW):** The lane-ownership index survives Save / Save As / reopen -(rides in the `"reasampler"` `view_state` alongside the membership index); -lane/mode-management actions are registered and bindable. -**Depends on:** D2-W2. +**Verify (in DAW):** Lane/mode-management actions are registered and bindable in +the Actions list; the panel UI indicator reflects the current lane/mode state. +**Depends on:** D2-W3-A. - [ ] Any new lane/mode-management actions (`command_id`/`gaccel`/`hookcommand`); bindable in the Actions list. -- [ ] Persist slice: serialize/deserialize the lane-ownership index in the - `"reasampler"` `view_state` section, alongside the membership index. - [ ] Any panel UI indicator for lane/mode state. +**D2-W3-A code-review polish (fold into this wave):** +- [ ] Simplify the redundant per-mint `I_NUMFIXEDLANES` re-read in `view.cpp` + (`applyMintPlan`): a single grow-and-track pass removes the second + `GetMediaTrackInfo_Value` call inside the mint loop. +- [ ] Optional shared item-read helper to remove duplicated `itemGuid`/`itemLaneName` + read logic between `view.cpp` and `bank_panel.cpp`. +- [ ] Optional defensive note/check in `reconcileManagedLanes` for the edge case + where a managed lane name encodes an unregistered mode id (log and skip rather + than silently record an orphaned ownership entry). + --- # Phase B — Multi-bank (parallel to the M0–M11 capture roadmap and Phase D) @@ -254,6 +265,34 @@ and product notes → *Fork 5 — settled*.) index-only and reversible). **Verify the drag hit-test doesn't collide with the M5 grid's multi-select drag.** +## B5 — sample-remove (the missing sample-level verb) +**Goal:** Drop an individual `Sample`'s index entry from a bank or the pool — +the sample-level companion to move/copy/evacuate/delete-bank. Index-only, +non-destructive to the file; exposes the `BankIndex::remove` primitive that +`bank_model` already has (wires it, does not add it). CONTEXT.md §Sample removal. +Product framing + open forks: `docs/product/removal-and-prune.md` §Sample-remove. +**Verify (in DAW):** Remove drops the selected sample's entry from the target +bank; a same-hash entry in another bank is untouched (no cross-bank dedup); +pool *contents* are removable while pool-container privileges hold; removing the +last index reference to a file leaves that file on disk (orphaned until prune — +never deleted by remove); non-destructive (index + ext-state only, no file, no +timeline item). +**Depends on:** B1, B2, B3 (action set), B4 (panel affordance). + +- [ ] Surface `BankIndex::remove` through `bank_book`: remove a `Sample` from a + bank's index; pool contents removable, pool-container privileges unchanged. +- [ ] "Remove selected sample(s)" action (`command_id`/`gaccel`/`hookcommand`), + MIDI-bindable; carries a `scope: this-bank | all-banks` seam (fork R-A — default + this-bank until Daniel settles). +- [ ] `bank_panel` remove affordance on the current selection (reuse M5 selection + model, as move/copy do). +- [ ] Confirm-on-last-reference guardrail: remove that orphans a file (no other + bank references it) confirms, naming the orphaned-until-prune consequence; + remove of a still-referenced sample does not confirm. +- [ ] Tests: remove drops the target entry; same-hash entry in another bank + survives; remove-from-pool allowed; last-reference remove leaves an orphan (file + untouched); non-destructive (no file/timeline mutation). + ## Phase B open questions All five forks settled by Daniel (2026-07-23): persistence key = fold pool into `banks`, retire legacy key (1a); delete drops members + add evacuate verb (2); move is the @@ -265,3 +304,102 @@ ready to scope into implementation waves. One polish detail remains: - **Active-bank indicator placement** — per-region headers vs. single header readout vs. lit-tab. "Unmistakable" is settled; only placement is open. Polish detail. (touches B4) + +- **B5 sample-remove forks (NEW, unsettled — need Daniel):** **R-A** — remove scope + (this-bank | all-banks | both); spec-of-record is this-bank-primary with a + scope-parameter seam. **R-B** — undo model for `"reasampler"` ext-state index + mutations (affects *all* of Phase B, surfaced by remove; move/copy/evacuate/ + delete-bank/remove are not on REAPER Ctrl-Z as specced). Lean: a single-snapshot + ReaSampler-internal "undo last bank change." Both in `docs/product/removal-and-prune.md`. + +--- + +# Phase R — Reclaim (file lifecycle: the prune path) + +> **New pillar, own lettered namespace.** Prune is the file-lifecycle path the +> capture and multi-bank specs forward-reference throughout ("files persist on disk +> until prune") but that had no phase, module, or point. It is the **only** operation +> in ReaSampler that deletes bytes off disk. Namespaced **`R` (Reclaim)** alongside +> `M`/`D`/`B` because it is a distinct pillar — it serves *every* orphan-producing +> path (delete-bank, sample-remove B5, potentially M10 re-capture), not just +> Multi-bank, and it carries a new risk class (file deletion) with its own +> invariants. Authoritative spec: **CONTEXT.md §Prune — file-lifecycle spec**. +> Product framing + phase-placement justification + forks: +> `docs/product/removal-and-prune.md` §Prune. When a point lands, doc-keeper moves it +> to `COMPLETED.md`. +> +> **Boundary (load-bearing):** *remove creates orphans; prune reclaims them.* No +> operation other than prune deletes a file; prune deletes only files no index +> references. A bank op that deletes a file is still a bug. +> +> **Depends on:** B1, B2 (needs the multi-bank book to union the referenced-set +> across all banks) and B5 conceptually (sample-remove is a primary orphan-producer, +> so remove-then-prune is the coherent pair — mirror of evacuate-then-delete). Does +> **not** depend on the B3/B4 UI. + +## R1 — prune-reconcile core (pure) +**Goal:** REAPER-free, filesystem-free reconciler — given the set of files present +in the bank folder and the set of files referenced by the book (unioned across all +banks, pool included), compute the orphan set. The mirror of +`ViewModeModel::reconcile(liveGuids)`, one level down (files instead of GUIDs). +CONTEXT.md §Prune (Module architecture — pure). +**Verify:** CTest green. **Prune null test:** a folder whose every file is +referenced deletes nothing; prune returns exactly `present − referenced` and +nothing else. Referenced-set unioned across every bank (a file referenced by any +bank — including via a copy — is never an orphan). + +- [ ] Prune-reconcile pure function: `(present, referenced) → orphans`, referenced + unioned across the whole book (copies keep a file alive). +- [ ] Tests: prune null test (all-referenced → empty); orphan = present−referenced; + a copied file referenced by a second bank survives; empty folder / empty book edge + cases. + +## R2 — prune shell + persist wiring (filesystem I/O, thin) +**Goal:** Enumerate the current project bank folder (M4 project-relative resolution), +supply the referenced-set (and, per fork R-D, the owned-file set) from the session, +feed the pure core, and produce a dry-run manifest. No deletion in this wave — the +report path only. CONTEXT.md §Prune (persist / prune shell). +**Verify (in DAW):** Dry-run reports the orphan count + reclaimed size (+ file list +for a small set) against the resolved current bank folder; resolves paths the same +way the index does (survives a Save-As relocation); deletes nothing. +**Depends on:** R1, B1, B2. + +- [ ] Prune shell: enumerate the resolved current bank folder; feed the pure core. +- [ ] Session supplies the referenced-set (union across the book); resolve the bank + folder via the M4 project-relative machinery. +- [ ] Dry-run manifest: orphan count + reclaimed size (+ files for a small set); + **no deletion in this wave.** + +## R3 — deletion + action (the destructive step, guarded) +**Goal:** The confirmed deletion step and the bindable "Prune bank folder" action: +dry-run-first, confirm-with-manifest, then reclaim the orphan set — via OS trash if +portably available (fork R-C), else unlink. CONTEXT.md §Prune (guardrails, API). +**Verify (in DAW):** "Prune bank folder" reports first, deletes only on explicit +confirm, and reclaims exactly the orphan set — never a referenced file, never a +hand-dropped non-bank file; the referenced/owned-set safety holds; non-bank and +capture invariants untouched. +**Depends on:** R2. **Forks R-C / R-D / R-E must be settled before this wave.** + +- [ ] "Prune bank folder" action (`command_id`/`gaccel`/`hookcommand`), + dry-run-first, confirm-to-delete. +- [ ] Deletion mechanism (fork R-C): OS trash if a portable move-to-trash is + verified available, else unlink with the dry-run/confirm guardrail. **Verify the + platform move-to-trash surface before use.** +- [ ] Orphan attribution (fork R-D): reclaim only the bank system's own leavings, + not hand-dropped folder files (owned-file manifest per the lean — see below). +- [ ] Trigger (fork R-E): manual-primary; optional "…and prune now" offered at the + delete-bank confirmation; **no** background sweep. + +## Phase R open questions (unsettled — need Daniel) +- **Fork R-C — deletion mechanism (OS trash vs. unlink).** Lean: trash if portably + available (recoverable), else unlink with strong dry-run/confirm. Needs Daniel + + a platform to-verify (SWELL / per-platform trash APIs). +- **Fork R-D — orphan attribution (owned-file manifest vs. index-diff vs. + folder-sweep).** Lean: **owned-file manifest**. **Design-the-seam call:** the + manifest is cheap to maintain from capture onward but a backfill cliff to + reconstruct later — so **start tracking owned files as part of capture / Phase B + now, even though prune ships in Phase R.** Folder-sweep rejected as unsafe (deletes + hand-dropped files). Needs Daniel (touches the persisted shape + ideally lands + earlier than R3). +- **Fork R-E — trigger (manual-only vs. offer-on-orphaning vs. periodic).** Lean: + manual-primary + optional delete-time "prune now"; no background sweep. Needs Daniel. diff --git a/docs/product/removal-and-prune.md b/docs/product/removal-and-prune.md new file mode 100644 index 0000000..5c924ff --- /dev/null +++ b/docs/product/removal-and-prune.md @@ -0,0 +1,354 @@ +# Removal & prune — product notes + +Framing, rationale, and open forks behind the two missing removal capabilities: +**sample-remove** (a sample-level index verb) and **prune** (the file-lifecycle +path CONTEXT.md keeps forward-referencing but never scoped). The tickable spec +lives in `PLAN.md` (Phase B point B5 for remove; **Phase R** for prune) and the +authoritative technical detail in `CONTEXT.md` (§Sample removal, §Prune — file +lifecycle). This doc holds the *why* — the workflow, the guardrails, the +index-vs-file boundary, and the forks that need a Daniel decision. + +Status: framed by product-designer (2026-07-23). Sample-remove forks R-A/R-B and +prune forks R-C/R-D/R-E are surfaced below; **none are settled** — they need a +Daniel call before B5 / Phase R implementation. + +--- + +## The one boundary that governs everything: index vs. file + +ReaSampler already draws a hard line, stated repeatedly in CONTEXT.md: **a bank +operation touches the *index*, never the *file*.** Move, copy, evacuate, and +delete-bank are all index-only; files persist on disk "until prune." Every +removal capability below sits on exactly one side of that line, and keeping the +two verbs on opposite sides is the whole design. + +- **Sample-remove drops an index entry.** It is the sample-level sibling of the + bank verbs — move/copy/evacuate all keep the sample *somewhere*; remove is + "drop this entry outright." It is **index-only, non-destructive to the file**, + and — exactly like a plain delete-bank of a non-empty bank — it can *create* + an orphan when it removes the last index reference to a file. It sits on the + **same side of the line as every existing Phase B op.** + +- **Prune deletes files off disk.** It is the *only* operation in the entire + system that removes bytes. It reconciles the physical bank folder against the + union of all bank indices and reclaims files referenced by no bank. It sits on + the **file side of the line, alone.** + +So the crisp statement, worth putting in the spec verbatim: + +> **Remove creates orphans; prune reclaims them.** Sample-remove and delete-bank +> drop index entries and may leave a file referenced by nothing. Prune is the +> single path that turns such an orphan back into free disk space. No other +> operation deletes a file; prune deletes *only* files no index references. + +This is why they are two different scope objects (Phase B vs. Phase R), even +though a naive reading ("both are 'delete' verbs") would lump them together. + +--- + +## Sample-remove — the missing sample-level verb + +### What the user is doing + +The user has a sample they no longer want in a bank (or in the pool): a bad take, +a duplicate they don't want collapsed, a sample they filed into "Drums" by +mistake. Today they can *relocate* it (move/copy/evacuate) but they cannot drop +it. Remove is the "get this out of here" verb. Two intents hide inside it, and +the distinction is a fork (R-A below): + +1. **Remove from *this* bank** — drop the entry from the bank the user is looking + at, leaving any copies in other banks untouched. (If the sample was copied + into "Drums" and also lives in the pool, remove-from-Drums leaves the pool copy + alone.) +2. **Remove from *everywhere*** — drop every index entry for this sample across + all banks in one act ("purge this sample from the library"). + +### Reconciling with existing invariants + +- **Collapse-by-hash:** unaffected. Remove operates on a specific `Sample` entry + in a specific `BankIndex` (the `remove` primitive `bank_model` already has, per + CLAUDE.md — B5 exposes it, it does not add it). Because dedup is per-bank and + cross-bank dedup is deliberately *not* enforced, "remove from this bank" and + "the same hash still lives in another bank" coexist cleanly — that is the same + coexistence copy already relies on. +- **Files-are-never-deleted-by-a-bank-op:** upheld. Remove is index-only, exactly + like move/copy/evacuate/delete-bank. When remove drops the *last* reference to a + file, it produces the **same orphaned-until-prune state** a non-empty + delete-bank already produces — a designed state, not a new hazard class. The + file is reclaimed later by prune, never by remove. +- **Pool privileges:** the pool cannot be *deleted, renamed, or evacuated*, but + individual samples **can** be removed from the pool — otherwise the pool would + become a roach-motel (samples check in, never leave except by moving to a named + bank). Remove-from-pool is allowed; it is the pool's own "drop this sample" + verb. (The pool-as-container privilege is untouched; only its *contents* are + removable.) +- **Non-destructive:** remove mutates only index + ext-state, touches no file and + no timeline item — the Phase B non-destructive guarantee extends to it verbatim. + +### Guardrails + +Remove is *less* dangerous than it first looks, because it never deletes a file — +the bytes survive on disk until an explicit prune. So the recovery story is: an +accidental remove loses the *index entry*, not the audio. But there are two +sharpnesses to guard: + +- **Last-reference remove is the orphan-maker.** Removing a sample that exists in + only one bank orphans its file (until prune). This is the same footgun as + non-empty delete-bank, and it deserves the same treatment: **confirm when the + remove drops the last index reference** ("Remove 'kick_03'? It is in no other + bank — its file will remain on disk until pruned."). A remove of a sample that + still lives in another bank is cheap and reversible-in-spirit (re-copy it back) + and need not confirm. This makes the confirmation *earned* by actual risk rather + than fired on every remove. +- **Undo.** REAPER's own undo stack does not know about ext-state index mutations. + Whether remove is undoable — and how — is a genuine question shared with the + rest of Phase B (see fork R-B); it is not remove-specific, but remove is where + it bites first because remove is the first verb whose *only* effect is + destruction of an index entry with no relocation. + +### Where it lives + +Remove is a `bank_book`/`BankIndex` verb (pure), a bindable `actions` entry, and a +`bank_panel` affordance on the current selection — the exact three-layer shape +every Phase B verb already takes. That is why it belongs **in Phase B as B5**, not +in a phase of its own: same modules, same pattern, same side of the index/file +line. It is the verb Phase B forgot, not a new pillar. + +--- + +## Prune — the file-lifecycle path CONTEXT.md kept promising + +### What the user is doing + +The user has been working for a while: capturing, re-capturing (M10), deleting +banks, removing samples. Each of those left files on disk that no index references +any more — the "orphaned-until-prune" state the multi-bank spec designs in on +purpose. Over a long project the bank folder accumulates dead `.wav` files that +cost disk and clutter. **Prune is the reclaim pass**: "sweep the bank folder, +delete the files nothing references, tell me what you reclaimed." + +This is the path CONTEXT.md forward-references in at least four places ("files +persist on disk until prune," "the capture/prune path reclaims it") but never +scopes. It is a real, promised capability with **no phase, no module, no point** +— a dangling reference the plan has to make good on. + +### The load-bearing precedent: prune-on-reconcile already exists for modes + +ReaSampler already shipped this exact shape once. Design View's `view_mode_model` +has **`ViewModeModel::reconcile(liveGuids)`** — a pure function fed the live set +(the tracks that still exist), returning the residual membership entries to drop +(CONTEXT.md §Design View: "prunes orphaned snapshots on every toggle/load; +tolerates unknown/stale GUIDs (prune on reconcile)"). Prune is the **file-pool +mirror of that pure pattern**: + +> `reconcile(liveGuids)` reconciles *membership entries* against *live tracks*. +> Prune reconciles *files on disk* against *referenced files* (the union of every +> bank's index). Same shape — feed the pure core the live set, get back the +> residuals — one level down (files instead of GUIDs). + +This is the "mirror the existing pure pattern" move the whole codebase is built +on (`bank_model`, `view_mode_model`, `bank_book` are all the same pure-registry +shape). The **pure part of prune** — "given the set of files on disk and the set +of files referenced by the book, compute the orphan set" — is a REAPER-free, +unit-testable function that belongs with the pure cores. Only the two ends are +shell work: *enumerating* the bank folder (filesystem I/O) and *deleting* the +orphans (filesystem I/O). Keep the decision (which files are orphans) pure and +tested; keep the I/O thin. This is the same pure/shell split as everything else. + +### Reconciling with existing invariants + +- **This is the one place the "files are never deleted" rule is *intentionally* + broken — and it must be the *only* one.** Every other invariant says "no bank op + deletes a file." Prune is explicitly not a bank op; it is the file-lifecycle op, + and its entire job is deletion. The spec must state this asymmetry loudly so a + reviewer never reads prune as violating the bank-op rule: **prune is the sole + file-deletion authority; a bank op that deletes a file is still a bug.** +- **Referenced-set is the union across *all* banks, pool included.** A file is an + orphan iff **no** bank in the book references it. Because copy means one file can + be referenced by several banks, prune must union references across the whole + book before deciding — deleting a file still referenced by "Drums" because it + left the pool would be catastrophic. The referenced-set computation is the + safety-critical core and the thing to test hardest (the null test of prune: + *prune never deletes a file that any index references*). +- **Relative-paths-only / project-relative resolution:** prune enumerates and + deletes within the project bank folder using the same M4 project-relative path + resolution the index uses. It must resolve the *same* way the index does, or it + could mis-identify orphans across a Save-As relocation. Prune runs against the + *resolved current* bank folder, never a stale absolute path. +- **Collapse-by-hash:** irrelevant to prune's decision (prune works on files and + references, not hashes) but worth noting: two index entries that collapsed onto + one file mean one file, multiple references — prune's union handles this for + free (the file is referenced, so it survives). +- **Determinism / bit-identical / null-test (capture):** untouched — prune sits + below the capture path entirely, same as multi-bank. + +### Guardrails — this is the genuinely destructive act + +Prune deletes real bytes irreversibly (a deleted `.wav` is gone unless it went to +an OS trash — see fork R-C). It earns the strongest guardrails in the product: + +- **Dry-run first, always.** Prune should *report before it deletes*: "12 files + (34 MB) are referenced by no bank. Delete them?" A prune that silently sweeps is + unacceptable for an irreversible file-delete. The dry-run (compute-and-report, + the pure core with no deletion) is arguably the *primary* surface, and the + actual deletion is the confirmed second step. This mirrors how every safe + garbage-collector / disk-cleaner works (npm prune --dry-run, git gc reporting, + Lightroom's "delete rejected photos" confirmation). +- **Confirm with a manifest.** The confirmation names the count and the reclaimed + size and — for a small set — the files. The user approves a *specific* deletion, + not an abstract "clean up." +- **Never touch a referenced file, and never touch a non-bank file.** Prune's + scope is *files in the bank folder that the book once owned and no longer + references*. A file that was never a bank file (a user dropped something into the + folder by hand) is out of scope — prune should only reclaim files it can + attribute to the bank system's own leavings, not act as a general folder cleaner. + (This is a fork — R-D — because "how does prune know a file was ever ours" + depends on whether we track a manifest of owned files.) +- **Recoverability via the OS trash, if we can get it (fork R-C).** If prune can + route deletions to the platform recycle bin / trash instead of unlinking, an + accidental prune is recoverable outside the app. Whether SWELL/the platform + layer gives us a portable "move to trash" is a to-verify — but the *default* + should be the safest deletion the platform affords. + +### Where it lives — and why it is its own phase, not a Phase B point + +Prune is **not** a Phase B point. Three reasons it earns its own lettered phase +(proposed **Phase R — Reclaim / file lifecycle**): + +1. **It is a different pillar.** Phase B is the *bank container* pillar + (index-only, non-destructive, above the file). Prune is the *file lifecycle* + pillar (the one path that deletes files). CONTEXT.md already names it as a + separate concern every time it says "the capture/**prune** path" — file + lifecycle is spoken of as its own thing, owned by neither the capture nor the + bank layer. Giving it its own phase matches how the spec already talks about it. +2. **It serves more than Phase B.** Orphans are produced by delete-bank *and* + sample-remove (B5) *and*, arguably, by M10 re-capture superseding an old file, + *and* by Design View's deleted-track residual files if any exist. Prune is the + downstream reclaim for *all* file-orphaning paths, not a Phase-B-internal + cleanup. A capability multiple pillars forward-reference should not be nested + inside one of them. +3. **It carries a new risk class and new invariants.** Every phase so far has been + non-destructive-to-files by construction. Prune is the first phase that deletes + files, so it needs its own invariant section (the prune null test, the + referenced-set union, the dry-run guarantee, trash-routing). Burying that under + a Phase B checkbox would hide the one genuinely destructive capability in the + product inside a phase whose headline invariant is "non-destructive." The + dissonance alone argues for separation. + +Lettered, per the established convention (`M` = capture pillar, `D` = Design View, +`B` = Banks): **`R` = Reclaim.** It reads correctly — Phase R is "the file +lifecycle pillar," not "a bank sub-step." + +**Sequencing:** Phase R depends on B1/B2 (it needs the multi-bank book to compute +the referenced-set union across all banks) and on B5 conceptually (sample-remove +is a primary orphan-producer, so prune is most useful once remove exists), but it +does not depend on the B3/B4 *UI*. It can land any time after the book exists; +practically it should follow B5 so the two removal verbs ship as a coherent pair +(remove-then-prune is the workflow, mirroring evacuate-then-delete). + +--- + +## Open forks — need a Daniel decision + +### Sample-remove + +**Fork R-A — remove scope: from-this-bank vs. from-everywhere vs. both.** +Does "remove sample" drop the entry from the bank in view (leaving copies in other +banks), purge every index entry across the whole book, or offer both as distinct +verbs? +- *from-this-bank* is the conservative, composable primitive (it is literally the + `BankIndex::remove` the model already has); "from everywhere" is then "remove + from each bank that holds it," which the user can also achieve by removing per + bank. It matches the partition mental model (fork 3): a sample is in one bank, so + remove-from-this-bank usually *is* remove-from-everywhere. +- *from-everywhere* as a distinct verb only earns its keep once copy has scattered + a sample across several banks; then "purge this everywhere" is a real + convenience. +- **Product-designer lean:** ship **from-this-bank** as B5's core verb (it is the + model primitive, it composes, it matches the partition model), and treat + *from-everywhere* as an optional convenience layered on top *only if* the copy + workflow proves to scatter samples in practice. Design the seam (the action can + take a "scope: this-bank | all-banks" parameter) so from-everywhere is a + later flag, not a rewrite — but default the shipped affordance to this-bank. + **Needs Daniel:** confirm this-bank-primary, or elevate from-everywhere to a + first-class co-equal verb now. + +**Fork R-B — undo model for index mutations (shared across Phase B, surfaced by +remove).** REAPER's undo stack does not cover `"reasampler"` ext-state index +mutations. So move/copy/evacuate/delete-bank/**remove** are, as specced, not on +REAPER's Ctrl-Z. Options: +- *(i)* Accept it: index mutations are not undoable via REAPER undo; the safety net + is the confirmations + the fact that files survive (nothing is lost but the + entry, which the user can re-create by re-capture/re-move). Cheapest. +- *(ii)* A ReaSampler-internal one-step undo for the last bank/index mutation (a + single snapshot of the book restored on a "ReaSampler: undo last bank change" + action). Bounded, cheap-ish, covers the "oops" case for all Phase B verbs at + once. +- *(iii)* Hook into REAPER's undo system properly (`Undo_BeginBlock` / + `Undo_EndBlock` with ext-state as undo state) — most integrated, most work, needs + SDK verification that ext-state participates in undo blocks. +- **Product-designer lean:** this is bigger than remove and should be decided for + **all of Phase B at once**, not per-verb. Lean *(ii)* — a single-snapshot + internal undo of the last bank/index change — as the pragmatic middle: it turns + every index verb's "oops" into a one-key recovery without wading into REAPER's + undo internals. **Needs Daniel** (and it may retro-touch B1–B4, so surfacing it + now rather than at B5 is deliberate — the seam is cheapest before B1 lands). + +### Prune + +**Fork R-C — deletion mechanism: unlink vs. OS trash.** Does prune `remove()` the +file (irreversible) or route to the platform recycle bin/trash (recoverable +outside the app)? Trash is the safer default; the question is whether SWELL / the +platform layer offers a portable move-to-trash, or whether we'd hand-roll it per +platform (Win `SHFileOperation`/`IFileOperation`, macOS `NSFileManager +trashItemAtURL`, Linux XDG trash spec). **Needs Daniel + a to-verify** on platform +support. Lean: **trash if portably available, unlink with a strong dry-run+confirm +guardrail if not.** + +**Fork R-D — orphan attribution: manifest-tracked vs. index-diff vs. +folder-sweep.** How does prune know a file in the bank folder is a *reclaimable +orphan* (a bank file no index references) rather than *a file that was never +ours* (a user dropped it in by hand)? +- *(i) folder-sweep* — treat every file in the bank folder not referenced by the + book as an orphan and reclaim it. Simplest; **dangerous** — it will delete a + user's hand-placed file, violating "only reclaim our own leavings." +- *(ii) index-diff only* — never sweep the folder; instead, capture the file's + identity at the moment its *last* index reference is dropped (remove/delete-bank) + and prune only that recorded set. Safe (only ever touches files we know we + orphaned) but requires recording orphans-at-drop-time. +- *(iii) owned-file manifest* — the book tracks the set of files it has ever + created (capture writes to it); prune reclaims `(owned ∩ on-disk) − referenced`. + Safest and most general (distinguishes "our orphan" from "user's file" and from + "already-gone"); costs a manifest that rides the ext-state. +- **Product-designer lean:** *(iii) owned-file manifest* — it is the honest + encoding of "prune reclaims only the bank system's own leavings," and it is a + **design-the-seam-now** call: the manifest is cheap to maintain from capture + onward but expensive to reconstruct retroactively (you cannot tell, later, which + folder files were ever ours). If we defer prune but *don't* start tracking owned + files now, we build a backfill cliff. **Strong recommendation: start the owned- + file manifest as part of Phase B / capture even though prune ships in Phase R** — + defer the feature, design the seam. **Needs Daniel** because it touches the + persisted shape (a new tracked set) and ideally lands earlier than Phase R. + +**Fork R-E — prune trigger: manual-only vs. offer-on-orphaning vs. periodic.** +When does prune run? Manual action only (user invokes "Prune bank folder"), +prompted opportunistically (after a non-empty delete-bank: "…evacuate, or delete +and prune now?"), or a background/periodic sweep? Lean: **manual-only as the core +(an explicit, dry-run-first action the user invokes deliberately)**; an *optional* +"…and prune now" affordance offered at the delete-bank confirmation as a +convenience, never a background sweep (silent irreversible file-deletion is exactly +what the guardrails forbid). **Needs Daniel** to confirm manual-primary and +whether the delete-time "prune now" convenience is wanted. + +--- + +## Summary of the boundary (for the spec) + +| | Sample-remove (B5) | Delete-bank (B1/B3, shipped-spec) | Prune (Phase R) | +|---|---|---|---| +| Object | one `Sample` entry | one bank + its member entries | files on disk | +| Side of the line | index | index | **file** | +| Deletes bytes? | no | no | **yes (only op that does)** | +| Produces orphans? | yes (last-ref) | yes (non-empty) | — (it *reclaims* them) | +| Reversible? | re-add/re-capture entry | re-create bank + re-add | **no** (unless trash, R-C) | +| Guardrail | confirm on last-ref | confirm on non-empty | dry-run + manifest confirm | From b3be9799e063c818cbab3647ead716ea6b2b1ace Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Thu, 23 Jul 2026 20:21:55 -0400 Subject: [PATCH 15/22] fix: apply dB compression to waveform thumbnail display Linear amplitude-to-pixel mapping made -20 dBFS content reach only 10% of cell height and -40 dBFS round to zero. Replace with a dB scale (floor kDisplayFloorDb = -60 dB, in bank_grid.h) so quiet content is visible. Pure helper compressAmplitudeForDisplay() lives in bank_grid; drawThumbnail() calls it. Five new unit tests cover full-scale, zero, mid-levels, floor clamping, and sign preservation. --- src/bank_grid.cpp | 25 +++++++++++++++++++ src/bank_grid.h | 20 +++++++++++++++ src/bank_panel.cpp | 4 +-- tests/test_bank_grid.cpp | 53 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 100 insertions(+), 2 deletions(-) diff --git a/src/bank_grid.cpp b/src/bank_grid.cpp index c393d1f..1cc3a53 100644 --- a/src/bank_grid.cpp +++ b/src/bank_grid.cpp @@ -3,6 +3,7 @@ #include "bank_grid.h" #include +#include namespace reasampler { @@ -199,4 +200,28 @@ Selection navigate(const Selection& current, NavKey key, int cols, int itemCount return s; } +float compressAmplitudeForDisplay(float linear) { + const float mag = linear < 0.0f ? -linear : linear; + + // The linear magnitude at the floor threshold: 10^(kDisplayFloorDb/20). + // Any magnitude at or below this maps to display fraction 0. + // Computed once as a constant expression; std::pow is constexpr in C++20 but + // not C++17, so derive it via the floor definition directly at runtime — it is + // only called once per bin, and the branch-free math is cheap. + const float floorMag = std::pow(10.0f, kDisplayFloorDb / 20.0f); + + if (mag <= floorMag) return 0.0f; // below floor (and guards log10(0)) + + // dB in [kDisplayFloorDb, 0] for magnitude in [floorMag, 1]. + const float db = 20.0f * std::log10(mag); + + // Normalize to [0, 1]: 0 at kDisplayFloorDb, 1 at 0 dB. + const float fraction = (db - kDisplayFloorDb) / (0.0f - kDisplayFloorDb); + + // Clamp to [0, 1] so floating-point overshoot on |linear| > 1.0 stays bounded, + // then re-apply the original sign. + const float clamped = fraction < 0.0f ? 0.0f : (fraction > 1.0f ? 1.0f : fraction); + return linear < 0.0f ? -clamped : clamped; +} + } // namespace reasampler diff --git a/src/bank_grid.h b/src/bank_grid.h index a0bf2a8..3616e96 100644 --- a/src/bank_grid.h +++ b/src/bank_grid.h @@ -163,4 +163,24 @@ enum class NavKey { Left, Right, Up, Down, Home, End }; Selection navigate(const Selection& current, NavKey key, int cols, int itemCount, bool shift); +// --- Waveform display compression -------------------------------------------- +// +// Maps a raw linear amplitude magnitude to a perceptual display fraction so +// quiet and medium content remains visible in the thumbnail. +// +// The floor below which amplitude is treated as silence (display fraction 0). +// At -60 dB, 0.001 linear magnitude maps to ~0. Tune this constant in-DAW to +// taste — it is the only knob for the compression curve. +constexpr float kDisplayFloorDb = -60.0f; + +// Maps a signed linear amplitude value in [-1, 1] (a raw envelope extreme such +// as PeakBin::max or PeakBin::min) to a signed display fraction in [-1, 1]. +// +// The magnitude |linear| is converted to dB, clamped to [kDisplayFloorDb, 0], +// then normalized so kDisplayFloorDb -> 0 and 0 dB -> 1. The original sign is +// re-applied so positive max values still map positive (draw up) and negative +// min values still map negative (draw down). Exact-zero input returns 0.0f +// (stays on the midline). Full-scale (|linear| == 1.0f) returns exactly ±1.0f. +float compressAmplitudeForDisplay(float linear); + } // namespace reasampler diff --git a/src/bank_panel.cpp b/src/bank_panel.cpp index f29c26e..6e68684 100644 --- a/src/bank_panel.cpp +++ b/src/bank_panel.cpp @@ -411,8 +411,8 @@ void drawThumbnail(LICE_IBitmap* bmp, const CellRect& rect, const Envelope& env, const int x = rect.x + 2 + (nbins > 1 ? (i * (innerW - 1)) / (nbins - 1) : 0); // min<=max always (peaks invariant). Draw a vertical line from the // min sample to the max sample, clamped to the band. - int yMax = midY - static_cast(bins[i].max * halfSpan); // max -> up - int yMin = midY - static_cast(bins[i].min * halfSpan); // min -> down + int yMax = midY - static_cast(compressAmplitudeForDisplay(bins[i].max) * halfSpan); // max -> up + int yMin = midY - static_cast(compressAmplitudeForDisplay(bins[i].min) * halfSpan); // min -> down if (yMax < bandTop) yMax = bandTop; if (yMin > bandTop + bandH - 1) yMin = bandTop + bandH - 1; LICE_Line(bmp, x, yMin, x, yMax, kColWaveform, 1.0f, 0, false); diff --git a/tests/test_bank_grid.cpp b/tests/test_bank_grid.cpp index 59d8cf3..9d178b8 100644 --- a/tests/test_bank_grid.cpp +++ b/tests/test_bank_grid.cpp @@ -15,6 +15,7 @@ #include "../src/bank_grid.h" +#include #include #include #include @@ -322,6 +323,52 @@ static void testNavDegenerate() { CHECK(selEq(navigate(Selection{{0}, 0, 0}, NavKey::Down, 0, 4, false), {1}, 1, 1)); } +// --- compressAmplitudeForDisplay ---------------------------------------------- + +// Full scale: magnitude 1.0 must reach the full display fraction exactly. +static void testCompressFullScale() { + CHECK(compressAmplitudeForDisplay(1.0f) == 1.0f); + CHECK(compressAmplitudeForDisplay(-1.0f) == -1.0f); +} + +// Exact zero must stay on the midline (no log of zero; guards the singularity). +static void testCompressZeroIsMidline() { + CHECK(compressAmplitudeForDisplay(0.0f) == 0.0f); +} + +// -20 dB (0.1 linear) and -40 dB (0.01 linear) must both produce clearly visible +// (non-zero) fractions, with -20 dB > -40 dB (monotonic), and both well above +// the midline (arbitrary threshold of 0.15 chosen conservatively — at a -60 dB +// floor, -20 dB normalizes to 2/3 and -40 dB to 1/3). +static void testCompressMidValuesVisible() { + const float f20 = compressAmplitudeForDisplay(0.1f); // -20 dBFS + const float f40 = compressAmplitudeForDisplay(0.01f); // -40 dBFS + CHECK(f20 > 0.15f); // clearly non-zero + CHECK(f40 > 0.15f); // clearly non-zero + CHECK(f20 > f40); // monotonic: louder -> taller bar +} + +// At and below the floor (-60 dB = 0.001 linear) the result is ~0 (silence). +// We test at exactly the floor magnitude and well below it. +static void testCompressAtAndBelowFloor() { + // 0.001 == 10^(-60/20) is the floor ratio. Magnitude at or below it -> 0. + const float floorMag = std::pow(10.0f, kDisplayFloorDb / 20.0f); // ~0.001 + CHECK(compressAmplitudeForDisplay(floorMag) == 0.0f); + CHECK(compressAmplitudeForDisplay(floorMag * 0.5f) == 0.0f); + CHECK(compressAmplitudeForDisplay(0.0001f) == 0.0f); +} + +// Sign is preserved: negative input produces a negative fraction of the same +// magnitude as its positive counterpart. +static void testCompressSignPreserved() { + const float pos = compressAmplitudeForDisplay(0.1f); + const float neg = compressAmplitudeForDisplay(-0.1f); + CHECK(neg < 0.0f); + // Magnitudes must be equal (sign-symmetric). + const float diff = pos + neg; // pos - |neg| + CHECK(diff > -0.001f && diff < 0.001f); +} + int main() { testColumnsForWidth(); testTooNarrowClampsToOneColumn(); @@ -355,6 +402,12 @@ int main() { testNavFromEmptyFocusesFirst(); testNavDegenerate(); + testCompressFullScale(); + testCompressZeroIsMidline(); + testCompressMidValuesVisible(); + testCompressAtAndBelowFloor(); + testCompressSignPreserved(); + if (g_fail == 0) std::printf("All tests passed.\n"); return g_fail ? 1 : 0; } From 960c5166eed020bedfbcbd94d01e879e16c9796c Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Thu, 23 Jul 2026 20:24:31 -0400 Subject: [PATCH 16/22] docs: record D2-W3-A landed in COMPLETED.md planLaneMinting pure decision, applyMintPlan shell, reconcileManagedLanes on load, persist via ViewModeModel view_state. CTest 14/14; DAW verify pending. (PLAN.md D2-W3-B narrowing already landed via a concurrent commit.) --- COMPLETED.md | 67 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/COMPLETED.md b/COMPLETED.md index f8041e7..deef77d 100644 --- a/COMPLETED.md +++ b/COMPLETED.md @@ -547,3 +547,70 @@ projects with no stored key fall back to Off / 2 s. (Off mode, 2 s stored length) — graceful for projects saved before this feature shipped. - `kProjExtTailKey = "tail_setting"` is forever-stable (changing it would orphan saved choices, falling back to the default — graceful but lossy). + +--- + +## D2-W3-A — lane minting + item→lane assignment + persist round-trip +**Goal:** The functional core that makes item-lanes appear: a pure `planLaneMinting` +decision (which tracks hold >1 mode's content, which managed lane each item lands on) +plus the shell apply path in `view.cpp` — enables fixed-lane mode, mints one managed +`reasampler:`-named lane per involved mode, assigns each item (including +pre-existing) to its mode's lane, and drives per-lane play state, all under one undo +block, triggered off the auto-tag detection tick. Reconciles the lane-ownership index +from durable lane names on project load before active-mode visibility is reapplied. +The lane-ownership index persists inside the `"reasampler"` `view_state` blob (rides +in `ViewModeModel::serialize()` / `deserialize()`). +**Verify:** CTest green (14/14). Pure decision unit-tested in `view_mode_model_tests`. +**DAW verification pending** (Daniel testing on dev): two behaviors are +REAPER-runtime-only — whether lane names stick when written on the same tick the track +flips to fixed-lane mode, and whether the leftover empty default lane 0 is silent. +**Depends on:** D2-W2. + +- [x] Pure `planLaneMinting` decision (`view_mode_model.{h,cpp}`): for each reported + track, collect the distinct modes of managed-eligible items; if < 2 modes, no split + (D1 whole-track parking still separates stances); if ≥ 2 modes, emit one + `TrackSplit`, one `LaneMint` per involved mode (durable key = `laneNameForMode(mode)`, + owned by that mode), and one `LaneAssign` per managed-eligible item — including + pre-existing items, so a track that just gained a second mode retroactively lanes all + its content. Manual-lane items (`onManualLane = true`) are exempt at the source: + never counted, never reassigned, never minted-over. +- [x] Shell apply path `applyMintPlan` in `view.cpp`: enables `I_FREEMODE` = fixed + lanes, grows `I_NUMFIXEDLANES` (never shrinks — user's manual lanes are never + deleted), stamps each managed lane's durable name via `P_LANENAME`, records + ownership in the model (`lanes().setManaged`), assigns each item to its mode's lane + via `I_FIXEDLANE` resolved from the durable key. Returns `changed` so the Undo block + is only kept when state actually changed (idempotent re-runs produce no undo point). +- [x] Per-lane play state driven immediately after minting: `planToggle` lane ops + applied via `applyLaneOps` so the freshly-minted lanes take the correct + `C_LANEPLAYS` state for the active mode without a full `applyMode` re-run (which + would re-park/restore whole tracks — not correct for a minting tick). +- [x] `mintManagedLanes` entry point in `view.cpp`: reads live track/item picture via + `readLaneTracks`, calls `planLaneMinting`, wraps the apply in one Undo block labelled + `"ReaSampler: separate cross-mode content into lanes"`, calls `UpdateTimeline()` + + `UpdateArrange()` after a fixed-lane mode change. +- [x] `reconcileManagedLanes` in `view.cpp`: on project load, reads every fixed-lane + track's `P_LANENAME` values; for each name carrying the managed prefix, records the + lane as managed-for-its-mode in the ownership index — pure read of REAPER state, no + lane created or renamed. Called from `main.cpp`'s load path before `applyMode`. +- [x] Lane-ownership index persists via `ViewModeModel::serialize()` / + `deserialize()` — the `LaneOwnershipIndex` is a member of `ViewModeModel` and + round-trips inside the `"reasampler"` `view_state` key alongside modes, membership, + snapshots, and active mode. No new persistence key required. +- [x] Detection tick integration: `mintManagedLanes` is called from the `bank_panel` + timer after the auto-tag pass, so a newly-tagged multi-mode track is split into lanes + on the same tick the content is detected. + +**Notes/decisions:** +- **Single-mode-track rule:** a track carrying content of only ONE mode is not split — + D1's whole-track parking continues to separate its stance from the other mode without + lane overhead. The lane-split only engages when a track genuinely holds ≥ 2 modes' + content. +- **Manual-lane invariant upheld at the source:** `planLaneMinting` never receives + manual-lane items as split candidates. The shell's `readLaneTracks` marks items on + manual lanes `onManualLane = true`; the pure decision skips them entirely. Managed + lanes are always appended (tail ordinals), never overwriting a user's existing lanes. +- **Idempotency:** re-reporting an already-split track produces the same plan; the + shell's ensure/assign writes are no-ops when state already matches. The Undo block is + closed with no label (discarded by REAPER) when the plan is non-empty but every write + was already satisfied, so no phantom undo points accumulate. +- **Review passed** with no Critical or Major findings. From 878d807b12264693b3bfba0e811bd0855b2cd859 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Fri, 24 Jul 2026 04:59:28 -0400 Subject: [PATCH 17/22] docs: settle removal/prune forks R-A..R-E; add owned-file manifest seam R-A this-bank; R-B batched REAPER undo points (Phase-B-wide, SDK must-verify); R-C trash-preferred w/ unlink fallback; R-D owned-file manifest, landed early as new B-cap capture point; R-E manual action + bank_panel button. --- CONTEXT.md | 156 ++++++++++++++---------- PLAN.md | 154 ++++++++++++++++-------- docs/product/removal-and-prune.md | 192 ++++++++++++++++-------------- 3 files changed, 296 insertions(+), 206 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index 569c841..9c4c886 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -771,13 +771,12 @@ model capability. container. Individual samples **can** be removed from the pool — otherwise the pool would be a one-way trap. Remove-from-pool is the pool's own "drop this sample" verb and is allowed. -- **Remove scope (fork R-A, OPEN — needs Daniel).** Whether "remove" drops the - entry from *this* bank only (leaving copies elsewhere), purges the sample from - *every* bank, or offers both. Spec-of-record until settled: **remove-from-this- - bank** is the core verb (it is the model primitive and composes into - from-everywhere); design the action to carry a `scope: this-bank | all-banks` - parameter so from-everywhere is a later flag, not a rewrite. See product notes - §Fork R-A. +- **Remove scope (fork R-A, SETTLED 2026-07-24 — this-bank).** Remove drops the + entry from *this* bank only, leaving copies in other banks untouched — the core + and only shipped verb. The action carries a `scope: this-bank | all-banks` seam, + but **this-bank is the settled default and the only surfaced affordance**; + all-banks stays a latent parameter (promotable later behind the seam without a + rewrite), never a surfaced verb now. See product notes §Fork R-A. ## Precision / invariant implications @@ -796,12 +795,18 @@ model capability. remove of a sample still referenced by another bank is cheap and re-derivable (re-copy it back) and needs no confirmation. The confirmation is *earned by actual orphan risk*, not fired on every remove. -- **Undo (fork R-B, OPEN — shared across all of Phase B).** REAPER's undo stack - does not cover `"reasampler"` ext-state mutations, so remove — like - move/copy/evacuate/delete-bank — is not on REAPER's Ctrl-Z as specced. The undo - model is a Phase-B-wide decision, not a remove-local one; see product notes - §Fork R-B. Surfaced here because remove is the first verb whose *only* effect is - index-entry destruction with no relocation, so it is where the gap first bites. +- **Undo (fork R-B, SETTLED 2026-07-24 — batched REAPER undo points, Phase-B-wide).** + Bank/index mutations integrate into REAPER's undo system as **batched undo points** + (`Undo_BeginBlock` / `Undo_EndBlock`): the related index mutations of one bank + operation are batched into a single undo point, so one bank operation is one + Ctrl-Z. This is a **Phase-B-wide** decision — it applies to + create/rename/reorder/delete-bank, move, copy, evacuate, *and* remove, retro- + touching B1–B4, not just B5. **Must-verify before build:** confirm against + `vendor/reaper-sdk` that `"reasampler"` ext-state mutations participate correctly + in `Undo_BeginBlock`/`Undo_EndBlock` undo blocks — the whole approach depends on + it. Surfaced with remove because remove is the first verb whose *only* effect is + index-entry destruction with no relocation, so it is where the gap first bit; the + fix is shared. See product notes §Fork R-B. ## Module architecture (preserve the pure/shell split) @@ -821,13 +826,14 @@ No new REAPER API. Pure model + a new action command-id string under the sampler family prefix + a panel affordance on the existing M5 LICE surface. Verify the command-id/gaccel/hookcommand usage against `main.cpp` (unchanged contract). -## Open questions +## Settled forks (Daniel, 2026-07-24) -- **Fork R-A — remove scope (this-bank | all-banks | both).** Open; needs Daniel. - Spec-of-record: this-bank primary with a scope-parameter seam. Product notes - §Fork R-A. -- **Fork R-B — undo model for index mutations.** Open; a Phase-B-wide decision - surfaced by remove. Product notes §Fork R-B. +- **Fork R-A — remove scope.** Settled: **this-bank** (this-bank-primary, all-banks + a latent seam-only parameter). Folded into Settled decisions above. +- **Fork R-B — undo model for index mutations.** Settled: **batched REAPER undo + points** (`Undo_BeginBlock`/`Undo_EndBlock`), Phase-B-wide (retro-touches B1–B4), + with the ext-state-participation SDK check as a must-verify-before-build. Folded + into Guardrails above and the Phase B / B1 plan points. --- @@ -902,9 +908,19 @@ pure/shell split as `bank_model` / `view_mode_model` / `bank_book`. irreversible file-delete. - **Scope is the bank system's own leavings, not the folder at large.** Prune reclaims files that *were* bank files and are now unreferenced — never a file a - user hand-dropped into the folder. How prune attributes ownership is fork R-D - (below). Prune is a reclaimer of ReaSampler's own orphans, not a general folder - cleaner. + user hand-dropped into the folder. Prune is a reclaimer of ReaSampler's own + orphans, not a general folder cleaner. +- **Orphan attribution is an owned-file manifest (fork R-D, SETTLED 2026-07-24).** + The book tracks the set of files it has created (an **owned-file manifest**); + prune reclaims `(owned ∩ on-disk) − referenced`. This is the honest encoding of + "reclaim only our own leavings" and rejects folder-sweep (which would delete + hand-dropped files). **The seam lands early:** because the manifest is cheap to + maintain from capture onward but a backfill cliff to reconstruct later, **capture + writes each file it creates into the owned-file manifest starting in Phase B**, + even though prune consumes it only in Phase R. R1/R2 consume the manifest; they do + not build it. The manifest is persisted in the `"reasampler"` ext-state; the exact + persistence shape (a sibling key vs. folded into the `banks` blob) is a small + build-time residual, but the manifest-now decision is firm. ## Precision / invariant implications @@ -928,35 +944,41 @@ pure/shell split as `bank_model` / `view_mode_model` / `bank_book`. - **Never a referenced file; never a non-bank file.** The union-across-all-banks rule protects referenced files; the ownership-attribution rule (fork R-D) protects hand-dropped files. -- **Safest platform deletion available (fork R-C, OPEN — needs Daniel + verify).** - Route deletions to the platform recycle bin / trash if a portable move-to-trash - is available (recoverable outside the app); fall back to unlink with the dry-run + - confirm guardrail if not. Default to the safest deletion the platform affords. -- **Manual, explicit trigger (fork R-E, OPEN).** Prune is a deliberate action the - user invokes, dry-run-first — never a silent background sweep. An optional "…and - prune now" convenience may be offered at the delete-bank confirmation; a periodic - background sweep is rejected (silent irreversible file-deletion violates the +- **Safest platform deletion available (fork R-C, SETTLED 2026-07-24 — trash- + preferred, unlink fallback).** Route deletions to the platform recycle bin / trash + wherever a portable move-to-trash is available (recoverable outside the app); fall + back to unlink — behind the dry-run + confirm guardrail — only where the platform + affords no portable trash. "Delete where possible" means recoverable-trash- + preferred, never plain unlink-by-default. The move-to-trash surface is an explicit + per-platform **to-verify** (see REAPER/platform API surface). +- **Manual, explicit trigger (fork R-E, SETTLED 2026-07-24 — manual action + panel + button).** Prune runs via a bindable manual action (dry-run-first, confirm-to- + delete) **and** a `bank_panel` button that fires that same action — never a silent + background sweep. The earlier optional "…and prune now at the delete-bank + confirmation" convenience was **not** selected and is out of scope; a periodic + background sweep remains rejected (silent irreversible file-deletion violates the guardrails). ## Module architecture (preserve the pure/shell split) Pure (no REAPER types, unit-tested — the mirror of `reconcile`): -- **Prune-reconcile core** — given `{ files present in the bank folder }` and - `{ files referenced by the book }` (and, per fork R-D, `{ files the book owns }`), - compute the orphan set. REAPER-free, filesystem-free, unit-tested hard (the prune - null test lives here). The referenced-set is unioned across all banks by asking - the `bank_book`. +- **Prune-reconcile core** — given `{ files present in the bank folder }`, + `{ files referenced by the book }`, and `{ files the book owns }` (the owned-file + manifest, R-D), compute the orphan set `(owned ∩ present) − referenced`. + REAPER-free, filesystem-free, unit-tested hard (the prune null test lives here). + The referenced-set is unioned across all banks by asking the `bank_book`. REAPER-facing / filesystem-facing (thin): -- `persist` / session — supplies the referenced-set (union across the book) and, - per fork R-D, the owned-file manifest; resolves the current project bank folder - via the M4 project-relative machinery. +- `persist` / session — supplies the referenced-set (union across the book) and the + owned-file manifest (R-D, written from capture onward in Phase B); resolves the + current project bank folder via the M4 project-relative machinery. - A **prune shell** — enumerates the bank folder (filesystem I/O), feeds the pure core, presents the dry-run manifest, and on confirmation deletes the orphan - set (via trash if available — fork R-C — else unlink). Filesystem I/O only; the - decision stays in the pure core. -- `actions` (entry) — "Prune bank folder" (dry-run-first, confirm-to-delete); - registered with the `command_id`/`gaccel`/`hookcommand` contract. + set (via OS trash where portably available — fork R-C — else unlink). Filesystem + I/O only; the decision stays in the pure core. +- `actions` (entry) — "Prune bank folder" (dry-run-first, confirm-to-delete), + registered with the `command_id`/`gaccel`/`hookcommand` contract; **plus a + `bank_panel` button** (R-E) that fires the same action. ## REAPER / platform API surface (verify all signatures) @@ -965,13 +987,15 @@ No new REAPER *audio* API. New surfaces to verify before use: project bank folder. **Verify** the portable approach against SWELL / the existing file-handling in `persist` / `capture` (which already resolve and write files); prefer reusing whatever path/file machinery M4 established. -- **Move-to-trash (fork R-C)** — if adopted, verify a portable move-to-trash - exists (SWELL, or per-platform: Win `IFileOperation`/`SHFileOperation`, macOS - `NSFileManager trashItemAtURL:`, Linux XDG trash spec). This is a **to-verify**, - not an assumed capability. -- **Persistence (fork R-D, if owned-file manifest adopted)** — a new tracked set in - the `"reasampler"` ext-state (or folded into the `banks` blob); shared M4 blob - machinery, new data only. +- **Move-to-trash (fork R-C, settled trash-preferred)** — verify a portable + move-to-trash exists (SWELL, or per-platform: Win `IFileOperation`/ + `SHFileOperation`, macOS `NSFileManager trashItemAtURL:`, Linux XDG trash spec). + This is a **must-verify per platform** before use, not an assumed capability; + where it is unavailable, fall back to unlink behind the dry-run/confirm guardrail. +- **Owned-file manifest persistence (fork R-D, settled)** — a new tracked set in + the `"reasampler"` ext-state (a sibling key or folded into the `banks` blob — + build-time residual); shared M4 blob machinery, new data only. **Written from + capture onward in Phase B** (the seam lands early), consumed by prune in Phase R. - **Actions** — the `command_id`/`gaccel`/`hookcommand` contract from `main.cpp` (unchanged), a new command-id string under the sampler family prefix. @@ -990,18 +1014,24 @@ No new REAPER *audio* API. New surfaces to verify before use: - **Additive only.** Prune reads the book and the folder; it does not modify `BankIndex`, `bank_book`, the capture roadmap, or Design View semantics. -## Open questions to resolve during build +## Settled forks (Daniel, 2026-07-24) -- **Fork R-C — deletion mechanism: OS trash vs. unlink.** Open; needs Daniel + a - platform to-verify. Lean: trash if portably available, else unlink with strong - dry-run/confirm. Product notes §Fork R-C. -- **Fork R-D — orphan attribution: owned-file manifest vs. index-diff vs. - folder-sweep.** Open; needs Daniel. Lean: **owned-file manifest**, and — because - the manifest is cheap to maintain from capture onward but a backfill cliff to - reconstruct later — **start tracking owned files as part of capture / Phase B - even though prune ships in Phase R** (defer the feature, design the seam). - Folder-sweep is rejected as unsafe (deletes hand-dropped files). Product notes - §Fork R-D. -- **Fork R-E — trigger: manual-only vs. offer-on-orphaning vs. periodic.** Open. - Lean: manual-primary (dry-run-first), an optional "…and prune now" at the - delete-bank confirmation, no background sweep. Product notes §Fork R-E. +- **Fork R-C — deletion mechanism.** Settled: **trash-preferred, unlink fallback.** + Route to the OS trash where a portable move-to-trash is available (recoverable), + else unlink behind the dry-run/confirm guardrail. Per-platform trash surface is a + must-verify. Folded into Settled decisions + Guardrails + API surface above. + Product notes §Fork R-C. +- **Fork R-D — orphan attribution.** Settled: **owned-file manifest**, `(owned ∩ + present) − referenced`; folder-sweep rejected as unsafe. The **seam lands early** — + capture writes each created file to the manifest starting in Phase B, prune + consumes it in Phase R. Persistence shape (sibling key vs. `banks` blob) is a + build-time residual. Folded into Settled decisions + Module architecture + API + surface above, and added as an up-front Phase B / capture plan point. Product + notes §Fork R-D. +- **Fork R-E — trigger.** Settled: **manual action + `bank_panel` button**, dry-run- + first, confirm-to-delete; no background sweep. The delete-time "…and prune now" + convenience was not selected (out of scope). Folded into Guardrails + Module + architecture above and the R3 plan points. Product notes §Fork R-E. + +**Build-time residual (not a fork):** the owned-file manifest's exact persistence +shape (sibling `"reasampler"` ext-state key vs. folded into the `banks` blob). diff --git a/PLAN.md b/PLAN.md index ba85b36..4273d95 100644 --- a/PLAN.md +++ b/PLAN.md @@ -194,6 +194,35 @@ active id. Legacy `bank_index` JSON parses into `{ pool }` with zero named banks with dest collapse; cross-bank same-hash coexistence; dest collapse on move into a bank already holding the hash; JSON lossless; legacy migration. +> **Phase-B-wide undo (fork R-B, settled 2026-07-24 — batched REAPER undo points).** +> Every index verb across B1–B5 (create/rename/reorder/delete-bank, move, copy, +> evacuate, remove) wraps its bank/index mutation in a **batched REAPER undo point** +> (`Undo_BeginBlock` / `Undo_EndBlock`), so one bank operation is one Ctrl-Z. This is +> a cross-cutting decision that retro-touches B1–B4, not a B5-local one; the +> per-verb points above inherit it. **Must-verify before build:** confirm against +> `vendor/reaper-sdk` that `"reasampler"` ext-state mutations participate correctly +> in `Undo_BeginBlock`/`Undo_EndBlock` undo blocks — the whole approach depends on +> it. See CONTEXT.md §Sample removal (Guardrails) + product notes §Fork R-B. + +## B-cap — owned-file manifest seam (capture writes; prune consumes in Phase R) +**Goal:** Capture writes each file it creates into an **owned-file manifest** +persisted in the `"reasampler"` ext-state, so Phase R prune can later distinguish +the bank system's own orphans from hand-dropped files. Consumed only in Phase R +(R1/R2) — landed early here because reconstructing the manifest retroactively is a +backfill cliff (fork R-D, settled 2026-07-24: *defer the feature, design the seam*). +CONTEXT.md §Prune (Settled decisions — orphan attribution) + product notes §Fork R-D. +**Verify:** every file the capture path creates is recorded in the owned-file +manifest; the manifest round-trips through the `"reasampler"` ext-state (Save / Save +As / reopen); relative-paths-only preserved. Prune's consumption of it is Phase R. +**Depends on:** the capture add-path (M7) + persist blob machinery (M4 / B2). + +- [ ] Capture records each created file into an owned-file manifest (the set of + files the book has created), persisted in the `"reasampler"` ext-state (sibling + key or folded into the `banks` blob — persistence shape is a small build-time + residual, not a fork). +- [ ] Manifest round-trips: survives Save / Save As / reopen via the M4 blob + machinery; relative-paths-only. (Consumed by Phase R R1/R2 — not consumed here.) + ## B2 — persist slice (banks ↔ project ext state) **Goal:** Serialize the book under the `banks` key in `"reasampler"` alongside the existing sections, with the pool folded in as bank-zero; migrate a legacy @@ -282,8 +311,9 @@ timeline item). - [ ] Surface `BankIndex::remove` through `bank_book`: remove a `Sample` from a bank's index; pool contents removable, pool-container privileges unchanged. - [ ] "Remove selected sample(s)" action (`command_id`/`gaccel`/`hookcommand`), - MIDI-bindable; carries a `scope: this-bank | all-banks` seam (fork R-A — default - this-bank until Daniel settles). + MIDI-bindable; carries a `scope: this-bank | all-banks` seam (fork R-A, settled + 2026-07-24: **this-bank** is the default and only surfaced affordance; all-banks + stays a latent seam-only parameter, not shipped). - [ ] `bank_panel` remove affordance on the current selection (reuse M5 selection model, as move/copy do). - [ ] Confirm-on-last-reference guardrail: remove that orphans a file (no other @@ -305,12 +335,16 @@ ready to scope into implementation waves. One polish detail remains: vs. lit-tab. "Unmistakable" is settled; only placement is open. Polish detail. (touches B4) -- **B5 sample-remove forks (NEW, unsettled — need Daniel):** **R-A** — remove scope - (this-bank | all-banks | both); spec-of-record is this-bank-primary with a - scope-parameter seam. **R-B** — undo model for `"reasampler"` ext-state index - mutations (affects *all* of Phase B, surfaced by remove; move/copy/evacuate/ - delete-bank/remove are not on REAPER Ctrl-Z as specced). Lean: a single-snapshot - ReaSampler-internal "undo last bank change." Both in `docs/product/removal-and-prune.md`. +**B5 sample-remove forks — settled 2026-07-24:** +- **R-A — remove scope.** Settled: **this-bank**. Removes the entry from the bank in + view only; the `scope: this-bank | all-banks` seam stays in the action signature + but all-banks is a latent parameter, not a surfaced verb. Folded into the B5 action + point above. +- **R-B — undo model (Phase-B-wide).** Settled: **batched REAPER undo points** + (`Undo_BeginBlock`/`Undo_EndBlock`), one bank op = one Ctrl-Z. Applies across + B1–B5 (retro-touches B1–B4) — captured as the cross-cutting note under B1 above, + with the ext-state-participation SDK check as a must-verify-before-build. +Both in `docs/product/removal-and-prune.md` §Fork R-A / §Fork R-B. --- @@ -338,68 +372,84 @@ ready to scope into implementation waves. One polish detail remains: > **not** depend on the B3/B4 UI. ## R1 — prune-reconcile core (pure) -**Goal:** REAPER-free, filesystem-free reconciler — given the set of files present -in the bank folder and the set of files referenced by the book (unioned across all -banks, pool included), compute the orphan set. The mirror of +**Goal:** REAPER-free, filesystem-free reconciler — given the files present in the +bank folder, the files referenced by the book (unioned across all banks, pool +included), and the **owned-file manifest** (fork R-D, written from capture onward by +B-cap), compute the orphan set `(owned ∩ present) − referenced`. The mirror of `ViewModeModel::reconcile(liveGuids)`, one level down (files instead of GUIDs). CONTEXT.md §Prune (Module architecture — pure). **Verify:** CTest green. **Prune null test:** a folder whose every file is -referenced deletes nothing; prune returns exactly `present − referenced` and -nothing else. Referenced-set unioned across every bank (a file referenced by any -bank — including via a copy — is never an orphan). +referenced deletes nothing; prune returns exactly `(owned ∩ present) − referenced` +and nothing else. Referenced-set unioned across every bank (a file referenced by any +bank — including via a copy — is never an orphan); a present-but-not-owned file (a +hand-dropped file) is never an orphan. -- [ ] Prune-reconcile pure function: `(present, referenced) → orphans`, referenced - unioned across the whole book (copies keep a file alive). -- [ ] Tests: prune null test (all-referenced → empty); orphan = present−referenced; - a copied file referenced by a second bank survives; empty folder / empty book edge - cases. +- [ ] Prune-reconcile pure function: `(present, referenced, owned) → orphans`, + computing `(owned ∩ present) − referenced`; referenced unioned across the whole + book (copies keep a file alive). +- [ ] Tests: prune null test (all-referenced → empty); orphan = (owned∩present)− + referenced; a copied file referenced by a second bank survives; a present-but- + unowned (hand-dropped) file is never reclaimed; empty folder / empty book / empty + manifest edge cases. ## R2 — prune shell + persist wiring (filesystem I/O, thin) **Goal:** Enumerate the current project bank folder (M4 project-relative resolution), -supply the referenced-set (and, per fork R-D, the owned-file set) from the session, -feed the pure core, and produce a dry-run manifest. No deletion in this wave — the -report path only. CONTEXT.md §Prune (persist / prune shell). +supply the referenced-set and the **owned-file manifest** (from B-cap) from the +session, feed the pure core, and produce a dry-run manifest. No deletion in this +wave — the report path only. CONTEXT.md §Prune (persist / prune shell). **Verify (in DAW):** Dry-run reports the orphan count + reclaimed size (+ file list for a small set) against the resolved current bank folder; resolves paths the same way the index does (survives a Save-As relocation); deletes nothing. **Depends on:** R1, B1, B2. - [ ] Prune shell: enumerate the resolved current bank folder; feed the pure core. -- [ ] Session supplies the referenced-set (union across the book); resolve the bank - folder via the M4 project-relative machinery. +- [ ] Session supplies the referenced-set (union across the book) **and the + owned-file manifest** (written by B-cap); resolve the bank folder via the M4 + project-relative machinery. - [ ] Dry-run manifest: orphan count + reclaimed size (+ files for a small set); **no deletion in this wave.** ## R3 — deletion + action (the destructive step, guarded) -**Goal:** The confirmed deletion step and the bindable "Prune bank folder" action: -dry-run-first, confirm-with-manifest, then reclaim the orphan set — via OS trash if -portably available (fork R-C), else unlink. CONTEXT.md §Prune (guardrails, API). -**Verify (in DAW):** "Prune bank folder" reports first, deletes only on explicit -confirm, and reclaims exactly the orphan set — never a referenced file, never a -hand-dropped non-bank file; the referenced/owned-set safety holds; non-bank and -capture invariants untouched. -**Depends on:** R2. **Forks R-C / R-D / R-E must be settled before this wave.** +**Goal:** The confirmed deletion step, the bindable "Prune bank folder" action, and +a `bank_panel` prune button: dry-run-first, confirm-with-manifest, then reclaim the +orphan set — via OS trash where portably available (fork R-C), else unlink. +CONTEXT.md §Prune (guardrails, API). +**Verify (in DAW):** "Prune bank folder" (action or panel button) reports first, +deletes only on explicit confirm, and reclaims exactly the orphan set — never a +referenced file, never a hand-dropped non-bank file; the referenced/owned-set safety +holds; deletions route to OS trash where available; non-bank and capture invariants +untouched. +**Depends on:** R2 (and B-cap's owned-file manifest). All forks settled 2026-07-24. - [ ] "Prune bank folder" action (`command_id`/`gaccel`/`hookcommand`), dry-run-first, confirm-to-delete. -- [ ] Deletion mechanism (fork R-C): OS trash if a portable move-to-trash is - verified available, else unlink with the dry-run/confirm guardrail. **Verify the - platform move-to-trash surface before use.** -- [ ] Orphan attribution (fork R-D): reclaim only the bank system's own leavings, - not hand-dropped folder files (owned-file manifest per the lean — see below). -- [ ] Trigger (fork R-E): manual-primary; optional "…and prune now" offered at the - delete-bank confirmation; **no** background sweep. +- [ ] `bank_panel` prune button (fork R-E) that fires the "Prune bank folder" + action through the existing command-id contract — the panel affordance alongside + the bindable action; split: button hit-test/layout is pure (mirror of + `mode_switch`/`bank_grid`), draw + dispatch is bank_panel shell. +- [ ] Deletion mechanism (fork R-C, settled trash-preferred): route to OS trash + where a portable move-to-trash is verified available, else unlink behind the + dry-run/confirm guardrail. **Verify the platform move-to-trash surface before use + (per platform).** +- [ ] Orphan attribution (fork R-D, settled owned-file manifest): reclaim only + `(owned ∩ present) − referenced` — the bank system's own leavings, never a + hand-dropped folder file. (Manifest written by B-cap; consumed via R1/R2.) -## Phase R open questions (unsettled — need Daniel) -- **Fork R-C — deletion mechanism (OS trash vs. unlink).** Lean: trash if portably - available (recoverable), else unlink with strong dry-run/confirm. Needs Daniel + - a platform to-verify (SWELL / per-platform trash APIs). -- **Fork R-D — orphan attribution (owned-file manifest vs. index-diff vs. - folder-sweep).** Lean: **owned-file manifest**. **Design-the-seam call:** the - manifest is cheap to maintain from capture onward but a backfill cliff to - reconstruct later — so **start tracking owned files as part of capture / Phase B - now, even though prune ships in Phase R.** Folder-sweep rejected as unsafe (deletes - hand-dropped files). Needs Daniel (touches the persisted shape + ideally lands - earlier than R3). -- **Fork R-E — trigger (manual-only vs. offer-on-orphaning vs. periodic).** Lean: - manual-primary + optional delete-time "prune now"; no background sweep. Needs Daniel. +## Phase R forks — settled 2026-07-24 +- **Fork R-C — deletion mechanism.** Settled: **trash-preferred, unlink fallback.** + Route to OS trash where a portable move-to-trash is available (recoverable), else + unlink behind strong dry-run/confirm. Per-platform trash surface (SWELL / Win + `SHFileOperation`·`IFileOperation` / macOS `trashItemAtURL:` / Linux XDG) is a + **must-verify before use**. Folded into R3. +- **Fork R-D — orphan attribution.** Settled: **owned-file manifest**, + `(owned ∩ present) − referenced`; folder-sweep rejected as unsafe. **Seam lands + early** — the manifest is written from capture onward (new **B-cap** point in + Phase B), not reconstructed at prune time; R1/R2 consume it. Persistence shape + (sibling `"reasampler"` key vs. `banks` blob) is a small build-time residual. +- **Fork R-E — trigger.** Settled: **manual action + `bank_panel` button**, + dry-run-first, confirm-to-delete. No background sweep. The earlier optional + delete-time "…and prune now" convenience was **not** selected — out of scope. + Folded into R3. + +Both docs of record: `docs/product/removal-and-prune.md` §Fork R-C/R-D/R-E and +CONTEXT.md §Prune (Settled forks). diff --git a/docs/product/removal-and-prune.md b/docs/product/removal-and-prune.md index 5c924ff..e5dc99b 100644 --- a/docs/product/removal-and-prune.md +++ b/docs/product/removal-and-prune.md @@ -8,9 +8,12 @@ authoritative technical detail in `CONTEXT.md` (§Sample removal, §Prune — fi lifecycle). This doc holds the *why* — the workflow, the guardrails, the index-vs-file boundary, and the forks that need a Daniel decision. -Status: framed by product-designer (2026-07-23). Sample-remove forks R-A/R-B and -prune forks R-C/R-D/R-E are surfaced below; **none are settled** — they need a -Daniel call before B5 / Phase R implementation. +Status: framed by product-designer (2026-07-23); **all five forks settled by Daniel +(2026-07-24)** — R-A this-bank-primary, R-B batched REAPER undo points +(Phase-B-wide), R-C trash-preferred-with-unlink-fallback, R-D owned-file manifest +(seam lands early in Phase B / capture), R-E manual action + panel button. The +decisions are folded into the fork sections below and into the B5 / Phase R spec +prose in CONTEXT.md and the tickable points in PLAN.md. --- @@ -100,11 +103,13 @@ sharpnesses to guard: still lives in another bank is cheap and reversible-in-spirit (re-copy it back) and need not confirm. This makes the confirmation *earned* by actual risk rather than fired on every remove. -- **Undo.** REAPER's own undo stack does not know about ext-state index mutations. - Whether remove is undoable — and how — is a genuine question shared with the - rest of Phase B (see fork R-B); it is not remove-specific, but remove is where - it bites first because remove is the first verb whose *only* effect is - destruction of an index entry with no relocation. +- **Undo.** REAPER's own undo stack does not natively cover ext-state index + mutations, so this is a Phase-B-wide decision (fork R-B, **settled**): bank/index + mutations integrate into REAPER's undo system as **batched undo points** + (`Undo_BeginBlock` / `Undo_EndBlock`), so one bank operation is one Ctrl-Z. Remove + is where the gap first bites — it is the first verb whose *only* effect is + destruction of an index entry with no relocation — but the fix is shared by every + Phase B verb. ### Where it lives @@ -204,11 +209,14 @@ an OS trash — see fork R-C). It earns the strongest guardrails in the product: attribute to the bank system's own leavings, not act as a general folder cleaner. (This is a fork — R-D — because "how does prune know a file was ever ours" depends on whether we track a manifest of owned files.) -- **Recoverability via the OS trash, if we can get it (fork R-C).** If prune can - route deletions to the platform recycle bin / trash instead of unlinking, an - accidental prune is recoverable outside the app. Whether SWELL/the platform - layer gives us a portable "move to trash" is a to-verify — but the *default* - should be the safest deletion the platform affords. +- **Recoverability via the OS trash (fork R-C, settled: trash-preferred).** Prune + routes deletions to the platform recycle bin / trash where a portable move-to-trash + is available, so an accidental prune is recoverable outside the app; it falls back + to unlink (behind the dry-run + confirm guardrail) only where the platform affords + no portable trash. Whether SWELL / the platform layer gives us that portable "move + to trash" is a to-verify per platform — but the *default* is the safest deletion + the platform affords, and "delete where possible" means recoverable-trash-preferred, + never plain unlink-by-default. ### Where it lives — and why it is its own phase, not a Phase B point @@ -248,97 +256,99 @@ practically it should follow B5 so the two removal verbs ship as a coherent pair --- -## Open forks — need a Daniel decision +## Settled forks (Daniel, 2026-07-24) ### Sample-remove -**Fork R-A — remove scope: from-this-bank vs. from-everywhere vs. both.** -Does "remove sample" drop the entry from the bank in view (leaving copies in other -banks), purge every index entry across the whole book, or offer both as distinct -verbs? -- *from-this-bank* is the conservative, composable primitive (it is literally the +**Fork R-A — remove scope. SETTLED: THIS BANK (this-bank-primary).** +Remove drops the entry from the bank in view only, leaving copies in other banks +untouched. This is the core (and shipped) verb. +- *from-this-bank* is the composable primitive (it is literally the `BankIndex::remove` the model already has); "from everywhere" is then "remove from each bank that holds it," which the user can also achieve by removing per bank. It matches the partition mental model (fork 3): a sample is in one bank, so remove-from-this-bank usually *is* remove-from-everywhere. -- *from-everywhere* as a distinct verb only earns its keep once copy has scattered - a sample across several banks; then "purge this everywhere" is a real - convenience. -- **Product-designer lean:** ship **from-this-bank** as B5's core verb (it is the - model primitive, it composes, it matches the partition model), and treat - *from-everywhere* as an optional convenience layered on top *only if* the copy - workflow proves to scatter samples in practice. Design the seam (the action can - take a "scope: this-bank | all-banks" parameter) so from-everywhere is a - later flag, not a rewrite — but default the shipped affordance to this-bank. - **Needs Daniel:** confirm this-bank-primary, or elevate from-everywhere to a - first-class co-equal verb now. +- **Decision:** ship **from-this-bank** as B5's core verb and the only surfaced + affordance. Keep the `scope: this-bank | all-banks` seam in the action signature + as designed, but **this-bank is the settled default and the only shipped verb**; + all-banks stays a *latent parameter*, not a surfaced convenience — it can be + promoted later behind that seam without a rewrite if the copy workflow proves to + scatter samples in practice. (Settled 2026-07-24, confirming the product-designer + lean; from-everywhere is explicitly *not* elevated to a co-equal verb now.) -**Fork R-B — undo model for index mutations (shared across Phase B, surfaced by -remove).** REAPER's undo stack does not cover `"reasampler"` ext-state index -mutations. So move/copy/evacuate/delete-bank/**remove** are, as specced, not on -REAPER's Ctrl-Z. Options: -- *(i)* Accept it: index mutations are not undoable via REAPER undo; the safety net - is the confirmations + the fact that files survive (nothing is lost but the - entry, which the user can re-create by re-capture/re-move). Cheapest. -- *(ii)* A ReaSampler-internal one-step undo for the last bank/index mutation (a - single snapshot of the book restored on a "ReaSampler: undo last bank change" - action). Bounded, cheap-ish, covers the "oops" case for all Phase B verbs at - once. -- *(iii)* Hook into REAPER's undo system properly (`Undo_BeginBlock` / - `Undo_EndBlock` with ext-state as undo state) — most integrated, most work, needs - SDK verification that ext-state participates in undo blocks. -- **Product-designer lean:** this is bigger than remove and should be decided for - **all of Phase B at once**, not per-verb. Lean *(ii)* — a single-snapshot - internal undo of the last bank/index change — as the pragmatic middle: it turns - every index verb's "oops" into a one-key recovery without wading into REAPER's - undo internals. **Needs Daniel** (and it may retro-touch B1–B4, so surfacing it - now rather than at B5 is deliberate — the seam is cheapest before B1 lands). +**Fork R-B — undo model for index mutations (Phase-B-wide, surfaced by remove). +SETTLED: BATCH UNDO POINTS (option (iii) — REAPER-integrated, batched).** +REAPER's undo stack does not natively cover `"reasampler"` ext-state index +mutations, so move/copy/evacuate/delete-bank/remove needed an undo story. Daniel +chose to integrate bank/index mutations into **REAPER's own undo system as batched +undo points** — the `Undo_BeginBlock` / `Undo_EndBlock` direction — batching the +related index mutations of one bank operation into a single undo point, so a bank +operation is one Ctrl-Z. The considered alternatives: +- *(i)* Accept no undo (rely on confirmations + files surviving) — **rejected**, too + weak once remove destroys an index entry with no relocation. +- *(ii)* A ReaSampler-internal single-snapshot "undo last bank change" — **rejected** + in favour of the more integrated (iii); the earlier product-designer lean toward + (ii) was overridden. +- *(iii)* **CHOSEN** — hook REAPER's undo system properly, batching related index + mutations into single undo points. +- **Scope — Phase-B-wide.** This is decided for **all of Phase B at once**, and it + **retro-touches B1–B4**, not just B5: every index verb (create/rename/reorder/ + delete-bank, move, copy, evacuate, remove) wraps its mutation in an undo block. + Surfaced with B1's open questions, not only at B5. +- **Must-verify-before-build (carry-forward).** The whole approach depends on + `"reasampler"` ext-state mutations participating correctly in + `Undo_BeginBlock`/`Undo_EndBlock` undo blocks. **Confirm against + `vendor/reaper-sdk` that ext-state changes are captured/restored by REAPER undo + blocks before building** — if they are not, the batched-undo-point approach does + not hold and the decision must be revisited. Flagged as a hard prerequisite. + (Settled 2026-07-24.) ### Prune -**Fork R-C — deletion mechanism: unlink vs. OS trash.** Does prune `remove()` the -file (irreversible) or route to the platform recycle bin/trash (recoverable -outside the app)? Trash is the safer default; the question is whether SWELL / the -platform layer offers a portable move-to-trash, or whether we'd hand-roll it per -platform (Win `SHFileOperation`/`IFileOperation`, macOS `NSFileManager -trashItemAtURL`, Linux XDG trash spec). **Needs Daniel + a to-verify** on platform -support. Lean: **trash if portably available, unlink with a strong dry-run+confirm -guardrail if not.** +**Fork R-C — deletion mechanism: unlink vs. OS trash. SETTLED: TRASH-PREFERRED, +UNLINK FALLBACK.** Prune routes deletions to the platform recycle bin / trash +(recoverable outside the app) **wherever the platform affords a portable +move-to-trash**, and falls back to unlink — behind the dry-run + confirm guardrail — +only where it does not. Trash is the settled default; "delete where possible" reads +as *recoverable-trash-preferred*, never plain unlink-by-default. +- **To-verify (carried, per platform):** whether a portable move-to-trash exists via + SWELL, or must be hand-rolled per platform — Win `SHFileOperation`/`IFileOperation`, + macOS `NSFileManager trashItemAtURL:`, Linux XDG trash spec. The move-to-trash + surface is an explicit to-verify before use, not an assumed capability. (Settled + 2026-07-24.) **Fork R-D — orphan attribution: manifest-tracked vs. index-diff vs. -folder-sweep.** How does prune know a file in the bank folder is a *reclaimable -orphan* (a bank file no index references) rather than *a file that was never -ours* (a user dropped it in by hand)? -- *(i) folder-sweep* — treat every file in the bank folder not referenced by the - book as an orphan and reclaim it. Simplest; **dangerous** — it will delete a - user's hand-placed file, violating "only reclaim our own leavings." -- *(ii) index-diff only* — never sweep the folder; instead, capture the file's - identity at the moment its *last* index reference is dropped (remove/delete-bank) - and prune only that recorded set. Safe (only ever touches files we know we - orphaned) but requires recording orphans-at-drop-time. -- *(iii) owned-file manifest* — the book tracks the set of files it has ever - created (capture writes to it); prune reclaims `(owned ∩ on-disk) − referenced`. - Safest and most general (distinguishes "our orphan" from "user's file" and from - "already-gone"); costs a manifest that rides the ext-state. -- **Product-designer lean:** *(iii) owned-file manifest* — it is the honest - encoding of "prune reclaims only the bank system's own leavings," and it is a - **design-the-seam-now** call: the manifest is cheap to maintain from capture - onward but expensive to reconstruct retroactively (you cannot tell, later, which - folder files were ever ours). If we defer prune but *don't* start tracking owned - files now, we build a backfill cliff. **Strong recommendation: start the owned- - file manifest as part of Phase B / capture even though prune ships in Phase R** — - defer the feature, design the seam. **Needs Daniel** because it touches the - persisted shape (a new tracked set) and ideally lands earlier than Phase R. +folder-sweep. SETTLED: OWNED-FILE MANIFEST — and the seam lands EARLY (Phase B / +capture).** The book tracks the set of files it has created; prune reclaims +`(owned ∩ on-disk) − referenced`. The considered alternatives: +- *(i) folder-sweep* — reclaim every unreferenced file in the folder. **Rejected** — + it would delete a user's hand-placed file, violating "only reclaim our own + leavings." +- *(ii) index-diff only* — record a file's identity when its *last* index reference + drops and prune only that set. Safe but partial (misses files orphaned outside a + tracked drop path). Not chosen. +- *(iii) owned-file manifest* — **CHOSEN.** Safest and most general: distinguishes + "our orphan" from "user's file" and from "already-gone." +- **Seam lands early (accepted design-the-seam-now call).** The manifest is cheap to + maintain from capture onward but a **backfill cliff** to reconstruct later — you + cannot tell, after the fact, which folder files were ever ours. Daniel accepted the + recommendation to **start the owned-file manifest at capture time NOW, in Phase B, + even though prune (which consumes it) ships in Phase R.** So: **capture writes each + file it creates into an owned-file manifest persisted in the `"reasampler"` + ext-state**, and Phase R's R1/R2 *consume* that manifest. The exact persistence + shape — a sibling ext-state key vs. folded into the `banks` blob — is a small + residual to settle at build; the **manifest-now decision is firm**. (Settled + 2026-07-24; the up-front point is added to Phase B / the capture path in PLAN.md.) -**Fork R-E — prune trigger: manual-only vs. offer-on-orphaning vs. periodic.** -When does prune run? Manual action only (user invokes "Prune bank folder"), -prompted opportunistically (after a non-empty delete-bank: "…evacuate, or delete -and prune now?"), or a background/periodic sweep? Lean: **manual-only as the core -(an explicit, dry-run-first action the user invokes deliberately)**; an *optional* -"…and prune now" affordance offered at the delete-bank confirmation as a -convenience, never a background sweep (silent irreversible file-deletion is exactly -what the guardrails forbid). **Needs Daniel** to confirm manual-primary and -whether the delete-time "prune now" convenience is wanted. +**Fork R-E — prune trigger: manual-only vs. offer-on-orphaning vs. periodic. +SETTLED: MANUAL ACTION + PANEL BUTTON.** Prune runs via a bindable manual action +(dry-run-first, confirm-to-delete) **and** a button in the `bank_panel` that fires +that same action. No background sweep. The earlier optional "…and prune now at the +delete-bank confirmation" convenience was **not** selected — it is dropped from the +settled spec (explicitly out of scope). A periodic/background sweep remains rejected +(silent irreversible file-deletion violates the guardrails). So R3 gains a +`bank_panel` button affordance alongside the action registration. (Settled +2026-07-24.) --- @@ -350,5 +360,5 @@ whether the delete-time "prune now" convenience is wanted. | Side of the line | index | index | **file** | | Deletes bytes? | no | no | **yes (only op that does)** | | Produces orphans? | yes (last-ref) | yes (non-empty) | — (it *reclaims* them) | -| Reversible? | re-add/re-capture entry | re-create bank + re-add | **no** (unless trash, R-C) | +| Reversible? | Ctrl-Z (batched undo, R-B) / re-capture | Ctrl-Z (batched undo, R-B) / re-create | **no in-app** (recoverable via OS trash, R-C) | | Guardrail | confirm on last-ref | confirm on non-empty | dry-run + manifest confirm | From 78a31ff2153d88de6ff35f5f5bf4974aea141666 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Fri, 24 Jul 2026 05:08:18 -0400 Subject: [PATCH 18/22] fix(view): lane-separate a content-bearing folder's own media when it's derived-visible in >1 mode planLaneMinting now takes the model + folder tree and splits a track visible in 2+ modes that carries own media (own-item span OR folder derived-visibility), fixing an item dropped on a cross-mode folder leaking into every mode. Show-both tracks are never force-split. --- src/view.cpp | 16 ++- src/view.h | 13 ++- src/view_mode_model.cpp | 74 +++++++++++--- src/view_mode_model.h | 72 ++++++++++---- tests/test_view_mode_model.cpp | 173 +++++++++++++++++++++++++++++++-- 5 files changed, 298 insertions(+), 50 deletions(-) diff --git a/src/view.cpp b/src/view.cpp index febaf06..fe7ca33 100644 --- a/src/view.cpp +++ b/src/view.cpp @@ -543,13 +543,19 @@ bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject bool mintManagedLanes(ViewModeModel& model, ReaProject* proj) { std::vector> handleByGuid; - readFolderEntries(proj, handleByGuid); // populates handleByGuid (tree unused here) + std::vector entries = readFolderEntries(proj, handleByGuid); + // The minting decision is now folder-tree / visibility aware: it needs the tree to + // detect a content-bearing folder derived-visible in >1 mode (which must lane-separate + // its own media even when that media is single-mode). Build it exactly as applyMode does. + const FolderTree tree = buildFolderTree(entries); - // 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. + // Build the live per-track item picture and run the PURE decision. A track visible in + // exactly one mode produces no split; a track visible in >1 mode while carrying its own + // media (own items span modes, OR a folder derived-visible across modes) produces mints + // + assignments. Manual-lane items are reported exempt inside readLaneTracks; show-both + // tracks are skipped inside the decision. const std::vector tracks = readLaneTracks(model, handleByGuid); - const LaneMintPlan plan = planLaneMinting(tracks); + const LaneMintPlan plan = planLaneMinting(model, tree, 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 diff --git a/src/view.h b/src/view.h index 3c519db..ae5feb0 100644 --- a/src/view.h +++ b/src/view.h @@ -52,13 +52,16 @@ 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). +// Mints managed fixed lanes for any track in `proj` that is VISIBLE IN MORE THAN ONE +// MODE while carrying its own media, and assigns each item to its mode's managed lane +// (Phase D2 Wave 3; visibility trigger added by the folder-media fix). // 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. +// lane (exempt). Builds the FolderTree (I_FOLDERDEPTH) so derived visibility counts. +// 2. Runs the pure planLaneMinting decision (model + tree aware). A track visible in +// exactly one mode is left whole-track-parked (D1) — NOT lane-split. A track visible +// in >1 mode while carrying own media splits: its own items span modes, OR it is a +// content-bearing folder derived-visible across modes. show-both tracks never 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 diff --git a/src/view_mode_model.cpp b/src/view_mode_model.cpp index 4b8dac8..7052f57 100644 --- a/src/view_mode_model.cpp +++ b/src/view_mode_model.cpp @@ -162,41 +162,85 @@ std::vector autoTagNewContent(const std::vector& newTrackG // lane minting decision // --------------------------------------------------------------------------- -LaneMintPlan planLaneMinting(const std::vector& tracks) { +LaneMintPlan planLaneMinting(const ViewModeModel& model, const FolderTree& tree, + const std::vector& tracks) { LaneMintPlan plan; + // Precompute, per track GUID, the count of modes it is VISIBLE in and the set of + // those mode ids — tree-aware, so a content-bearing folder's DERIVED visibility + // (visibleTracks marks a parent visible in every mode a descendant is visible in) + // is captured, not only the track's own item mode-span. This is the visibility + // trigger source (b): a folder derived-visible in >= 2 modes must lane-separate its + // own media even when that media is single-mode. Computed once for all tracks. + std::map> visibleModesOf; + for (const Mode& mode : model.modes().all()) { + const std::set vis = model.visibleTracks(tree, mode.id); + for (const std::string& guid : vis) + visibleModesOf[guid].insert(mode.id); + } + for (const LaneTrack& track : tracks) { if (track.trackGuid.empty()) continue; - // Collect the DISTINCT modes the track's managed-eligible items belong to, in + // SHOW-BOTH escape hatch: never force-split. A show-both track is visible in + // every mode ON PURPOSE and its content is meant to play across all of them, so + // neither the visibility trigger nor the own-item-span trigger confines it. Skip + // it entirely (no split/mint/assign) so its items stay cross-mode-visible. + if (model.membership().isShowBoth(track.trackGuid)) continue; + + // Collect the DISTINCT modes the track's managed-eligible OWN 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; + std::set ownItemModes; 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); + ownItemModes.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; + // A track with NO managed-eligible own media never splits: there is nothing to + // confine (lane separation projects OWN items across modes). A folder derived- + // visible in many modes but carrying no own content stays whole-track visibility- + // only (D1 parent handling) — this guards the "carries its own media" clause. + if (ownItemModes.empty()) continue; - // Multi-mode track: transition to lane-split. One managed lane per involved - // mode (durable key = laneNameForMode(mode)), owned by that mode. + // The two visibility sources, OR'd: + // (a) own items span >= 2 modes (W3-A trigger), and + // (b) the track is derived-visible in >= 2 modes (the folder-media case). + // A track qualifies for a split if EITHER makes it multi-mode. + const auto visIt = visibleModesOf.find(track.trackGuid); + const std::size_t visibleModeCount = + visIt == visibleModesOf.end() ? 0 : visIt->second.size(); + const bool multiMode = ownItemModes.size() >= 2 || visibleModeCount >= 2; + + // Single-mode (visible in exactly one mode, own items single-mode): 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 (!multiMode) continue; + + // Lanes to mint = the UNION of the modes the own items belong to and the modes the + // track is visible in. A folder whose own item is Design-only but which is derived- + // visible in Arrange too mints BOTH a Design lane (holding the item) and an Arrange + // lane (reserving Arrange's stance slot), matching "each mode owns a fixed lane." + std::set laneModes = ownItemModes; + if (visIt != visibleModesOf.end()) + laneModes.insert(visIt->second.begin(), visIt->second.end()); + + // 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) { + track.trackGuid, static_cast(laneModes.size())}); + for (const std::string& mode : laneModes) { 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. + // Assign EVERY managed-eligible OWN item onto its tagged mode's lane — including + // the pre-existing single-mode items, so a folder carrying one own Design item + // while derived-visible in Arrange still lanes that item to the Design lane (it + // then hides+silences whenever Arrange is active — the exact failing-case fix). for (const LaneItem& item : track.items) { if (item.guid.empty() || item.modeId.empty()) continue; if (item.onManualLane) continue; // exempt — never reassigned diff --git a/src/view_mode_model.h b/src/view_mode_model.h index 01c2c78..deffc33 100644 --- a/src/view_mode_model.h +++ b/src/view_mode_model.h @@ -522,16 +522,36 @@ std::vector autoTagNewContent(const std::vector& newTrackG // -- 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. +// is VISIBLE IN MORE THAN ONE MODE while carrying its OWN media, whole-track parking +// can no longer keep the stances separate (the track shows in every mode it is visible +// in, so its items leak across all of them), 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. +// +// "Visible in more than one mode" has TWO sources, and both trigger a split: +// (1) the track's OWN managed-eligible items span >= 2 modes (a leaf carrying both +// an Arrange take and a Design take), OR +// (2) the track is a content-bearing FOLDER whose descendant leaves span modes, so +// it is DERIVED-VISIBLE in >= 2 modes (ViewModeModel::visibleTracks) even though +// its own single item is single-mode. This second source is why the decision is +// folder-tree / visibility aware — mirroring visibleTracks — rather than looking +// only at the track's own item mode-span. Without it, one MIDI item or capture +// dropped straight onto such a folder sits on the default lane and leaks into +// every mode the folder derives visibility in. +// +// SHOW-BOTH is the deliberate escape hatch: a show-both track is visible in every mode +// ON PURPOSE and its content is meant to play in all of them. It is NEVER force-split — +// neither the visibility trigger nor the own-item-span trigger confines its items to +// per-mode lanes. (Confining show-both content would contradict "stay audible across +// modes.") The decision skips show-both tracks entirely. // // 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 shell reads each track's items and their live mode+lane disposition, builds the +// FolderTree (via the existing view_tree helper, exactly as the D1 shell does), calls +// this with the model + tree, 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 @@ -605,17 +625,30 @@ struct LaneMintPlan { } }; -// The pure lane-minting decision. For each reported track: +// The pure lane-minting decision, folder-tree / visibility aware. `model` supplies the +// membership + show-both state; `tree` supplies the folder structure so a content-bearing +// folder's DERIVED visibility is accounted for (mirrors ViewModeModel::visibleTracks). +// For each reported track: +// * SHOW-BOTH tracks are skipped outright — never force-split (the escape hatch: their +// content is meant to stay audible in every mode). No split, mint, or assignment. // * 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. +// * A track splits iff it CARRIES OWN managed-eligible media AND is VISIBLE IN >= 2 +// MODES. Visibility spans two sources, either of which qualifies: +// (a) the track's own managed-eligible items span >= 2 modes (leaf carrying an +// Arrange take and a Design take), OR +// (b) the track is derived-visible in >= 2 modes per visibleTracks (a content- +// bearing folder whose descendant leaves span modes) — the missed case. +// * A track visible in exactly ONE mode (single-mode leaf, single-mode folder) stays +// whole-track-parked (D1) — NO split. This is the single-mode-track rule. +// * On a split: one TrackSplit (laneCount == number of lanes to mint), one LaneMint per +// involved mode, and one LaneAssign per managed-eligible OWN item onto ITS tagged +// mode's lane — INCLUDING pre-existing items, so a folder carrying one own Design item +// while derived-visible in Arrange too still lanes that item to the Design lane (it +// then hides+silences whenever Arrange is active). The lanes minted are the union of: +// the modes the track's own items belong to, PLUS the modes the track is visible in — +// so a folder whose own item is Design-only but which is derived-visible in Arrange +// mints BOTH a Design lane (holding the item) and an Arrange lane (empty, reserving +// the Arrange stance's slot), matching "each mode owns a fixed lane." // // 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 @@ -624,7 +657,8 @@ struct LaneMintPlan { // 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); +LaneMintPlan planLaneMinting(const ViewModeModel& model, const FolderTree& tree, + 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 diff --git a/tests/test_view_mode_model.cpp b/tests/test_view_mode_model.cpp index f994fb0..5e3f5f5 100644 --- a/tests/test_view_mode_model.cpp +++ b/tests/test_view_mode_model.cpp @@ -1105,6 +1105,13 @@ static int splitLaneCount(const LaneMintPlan& p, const std::string& track) { return -1; // no split for this track } +// A plain LEAF track (not a folder) carrying its own items, with no tree derivation: +// an empty model + empty tree means visibleTracks contributes nothing, so the ONLY +// trigger is the track's own-item mode span — exactly the W3-A behavior. These helpers +// keep the W3-A leaf tests reading against a neutral model/tree. +static const ViewModeModel& bareModel() { static ViewModeModel m; return m; } +static const FolderTree& emptyTree() { static FolderTree t; return t; } + 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. @@ -1114,12 +1121,12 @@ static void testLaneMintingSingleModeNoSplit() { LaneItem{"{i2}", kArrangeModeId, false}, }}, }; - const LaneMintPlan plan = planLaneMinting(tracks); + const LaneMintPlan plan = planLaneMinting(bareModel(), emptyTree(), tracks); CHECK(plan.empty()); CHECK(splitLaneCount(plan, "{T}") == -1); // An empty track (no items) is likewise never split. - CHECK(planLaneMinting({LaneTrack{"{E}", {}}}).empty()); + CHECK(planLaneMinting(bareModel(), emptyTree(), {LaneTrack{"{E}", {}}}).empty()); } static void testLaneMintingMultiModeMintsAndAssignsAll() { @@ -1133,7 +1140,7 @@ static void testLaneMintingMultiModeMintsAndAssignsAll() { LaneItem{"{des1}", kDesignModeId, false}, // the newly-added 2nd-mode item }}, }; - const LaneMintPlan plan = planLaneMinting(tracks); + const LaneMintPlan plan = planLaneMinting(bareModel(), emptyTree(), tracks); CHECK(!plan.empty()); // One split with two managed lanes (one per involved mode). @@ -1160,7 +1167,7 @@ static void testLaneMintingManualLaneExempt() { LaneItem{"{comp}", kDesignModeId, /*onManualLane=*/true}, // user's comp take }}, }; - const LaneMintPlan plan = planLaneMinting(tracks); + const LaneMintPlan plan = planLaneMinting(bareModel(), emptyTree(), tracks); // Split for the two managed modes; the manual item never appears in assigns. CHECK(splitLaneCount(plan, "{T}") == 2); @@ -1179,7 +1186,8 @@ static void testLaneMintingManualLaneExempt() { LaneItem{"{d}", kDesignModeId, /*onManualLane=*/true}, // only 2nd mode, exempt }}, }; - CHECK(planLaneMinting(t2).empty()); // managed-eligible content is single-mode ⇒ no split + // managed-eligible content is single-mode ⇒ no split (leaf, no tree derivation). + CHECK(planLaneMinting(bareModel(), emptyTree(), t2).empty()); } static void testLaneMintingThreeModesAndOwnershipKeys() { @@ -1197,7 +1205,8 @@ static void testLaneMintingThreeModesAndOwnershipKeys() { LaneItem{"{m}", "mixdown", false}, }}, }; - const LaneMintPlan plan = planLaneMinting(tracks); + // Own items span three modes (leaf; empty tree ⇒ own-item-span is the sole trigger). + const LaneMintPlan plan = planLaneMinting(vm, FolderTree{}, tracks); CHECK(splitLaneCount(plan, "{T}") == 3); CHECK(plan.mints.size() == 3); @@ -1221,6 +1230,153 @@ static void testLaneMintingThreeModesAndOwnershipKeys() { if (back) CHECK(back->lanes().size() == 3); } +// -- Fix: content-bearing folder derived-visible in >1 mode splits its own media ---- +// +// The exact failing case. A folder {F} has descendant leaves in BOTH modes ({LD} Design, +// {LA} Arrange) and carries ONE OWN item ({own}) tagged Design. W3-A's own-item-span test +// alone would NOT split {F} (its own content is single-mode Design), so the item leaked +// into every mode the folder was derived-visible in. The visibility-aware decision now +// mints managed lanes on {F} and lanes {own} onto the Design lane — so it hides+silences +// whenever Arrange is active. This is the load-bearing fix; assert it hard. +static void testLaneMintingFolderDerivedVisibleSplitsOwnMedia() { + ViewModeModel vm; + vm.membership().tag("{LD}", kDesignModeId); // a Design leaf under the folder + // {LA} left untagged ⇒ Arrange member; both stances thus live under {F}. + vm.membership().tag("{own}", kDesignModeId); // the folder's OWN dropped item (Design) + + FolderTree tree; + tree.nodes.push_back(FolderNode{"{F}", "", /*isParent=*/true}); + tree.nodes.push_back(FolderNode{"{LD}", "{F}", false}); + tree.nodes.push_back(FolderNode{"{LA}", "{F}", false}); + + // Sanity: the folder really is derived-visible in BOTH modes (the precondition the + // W3-A trigger ignored). If this ever stops holding, the fix's premise is gone. + CHECK(vm.visibleTracks(tree, kArrangeModeId).count("{F}") == 1); + CHECK(vm.visibleTracks(tree, kDesignModeId).count("{F}") == 1); + + // The folder track {F} carries its own single Design item; its child leaves are the + // separate leaf tracks (not reported as items on {F}). + std::vector tracks{ + LaneTrack{"{F}", {LaneItem{"{own}", kDesignModeId, false}}}, + }; + + const LaneMintPlan plan = planLaneMinting(vm, tree, tracks); + + // {F} MUST split even though its own item is single-mode: it is visible in 2 modes. + CHECK(!plan.empty()); + CHECK(splitLaneCount(plan, "{F}") == 2); // one lane per stance + CHECK(hasMint(plan, "{F}", kDesignModeId)); // Design lane (holds the item) + CHECK(hasMint(plan, "{F}", kArrangeModeId)); // Arrange lane (reserves the slot) + + // The own item is confined to its tagged (Design) lane — the exact hide-in-Arrange fix. + CHECK(plan.assigns.size() == 1); + CHECK(hasAssign(plan, "{own}", "{F}", kDesignModeId)); + for (const auto& a : plan.assigns) + CHECK(!(a.itemGuid == "{own}" && a.laneKey == laneNameForMode(kArrangeModeId))); +} + +// A folder carrying its OWN items that already span both modes → still split (the two +// triggers OR: own-item span AND derived visibility both point the same way here). Both +// own items separate to their tagged lanes. +static void testLaneMintingFolderOwnItemsSpanBothModes() { + ViewModeModel vm; + vm.membership().tag("{LD}", kDesignModeId); + vm.membership().tag("{d}", kDesignModeId); + // {a} untagged ⇒ Arrange. + + FolderTree tree; + tree.nodes.push_back(FolderNode{"{F}", "", true}); + tree.nodes.push_back(FolderNode{"{LD}", "{F}", false}); + tree.nodes.push_back(FolderNode{"{LA}", "{F}", false}); // untagged ⇒ Arrange + + std::vector tracks{ + LaneTrack{"{F}", { + LaneItem{"{a}", kArrangeModeId, false}, + LaneItem{"{d}", kDesignModeId, false}, + }}, + }; + const LaneMintPlan plan = planLaneMinting(vm, tree, tracks); + CHECK(splitLaneCount(plan, "{F}") == 2); + CHECK(plan.assigns.size() == 2); + CHECK(hasAssign(plan, "{a}", "{F}", kArrangeModeId)); + CHECK(hasAssign(plan, "{d}", "{F}", kDesignModeId)); +} + +// SHOW-BOTH escape hatch: a show-both track carrying its own items is visible in every +// mode ON PURPOSE and must NOT be force-split — its content stays cross-mode-visible. +// Even with own items that would otherwise span modes, the decision skips it entirely. +static void testLaneMintingShowBothNotForceSplit() { + ViewModeModel vm; + vm.membership().setShowBoth("{SB}", true); + + // A show-both track whose OWN items even span two modes — the W3-A own-span trigger + // would fire, but show-both must override it (its items are meant to play everywhere). + std::vector tracks{ + LaneTrack{"{SB}", { + LaneItem{"{a}", kArrangeModeId, false}, + LaneItem{"{d}", kDesignModeId, false}, + }}, + }; + const LaneMintPlan plan = planLaneMinting(vm, FolderTree{}, tracks); + CHECK(plan.empty()); // NOT split — the escape hatch holds + CHECK(splitLaneCount(plan, "{SB}") == -1); + + // And a show-both FOLDER derived-visible in both modes carrying an own item: still not + // split. Visibility is the deliberate point of show-both. + ViewModeModel vm2; + vm2.membership().setShowBoth("{F}", true); + vm2.membership().tag("{LD}", kDesignModeId); + vm2.membership().tag("{own}", kDesignModeId); + FolderTree tree; + tree.nodes.push_back(FolderNode{"{F}", "", true}); + tree.nodes.push_back(FolderNode{"{LD}", "{F}", false}); + tree.nodes.push_back(FolderNode{"{LA}", "{F}", false}); + std::vector t2{LaneTrack{"{F}", {LaneItem{"{own}", kDesignModeId, false}}}}; + CHECK(planLaneMinting(vm2, tree, t2).empty()); +} + +// A single-mode LEAF visible in exactly one mode is still never split — the D1 whole-track +// parking case. A leaf under a folder, tagged Design, whose sibling is also Design: the +// leaf is visible in one mode only, carries its own Design item, and must NOT lane-split. +static void testLaneMintingSingleModeLeafVisibleOnceNoSplit() { + ViewModeModel vm; + vm.membership().tag("{L}", kDesignModeId); + vm.membership().tag("{own}", kDesignModeId); + + FolderTree tree; + tree.nodes.push_back(FolderNode{"{F}", "", true}); + tree.nodes.push_back(FolderNode{"{L}", "{F}", false}); // the leaf under test + + // The leaf {L} is visible only in Design (its one tagged mode). + CHECK(vm.visibleTracks(tree, kDesignModeId).count("{L}") == 1); + CHECK(vm.visibleTracks(tree, kArrangeModeId).count("{L}") == 0); + + std::vector tracks{ + LaneTrack{"{L}", {LaneItem{"{own}", kDesignModeId, false}}}, + }; + const LaneMintPlan plan = planLaneMinting(vm, tree, tracks); + CHECK(plan.empty()); // single-mode, visible once ⇒ D1 whole-track parking, no split +} + +// A content-EMPTY folder derived-visible in many modes carries NO own media, so there is +// nothing to lane-separate: it stays visibility-only (D1 parent handling), never split. +static void testLaneMintingEmptyFolderNotSplit() { + ViewModeModel vm; + vm.membership().tag("{LD}", kDesignModeId); + // {LA} untagged ⇒ Arrange; folder derived-visible in both modes but holds no own item. + + FolderTree tree; + tree.nodes.push_back(FolderNode{"{F}", "", true}); + tree.nodes.push_back(FolderNode{"{LD}", "{F}", false}); + tree.nodes.push_back(FolderNode{"{LA}", "{F}", false}); + + CHECK(vm.visibleTracks(tree, kArrangeModeId).count("{F}") == 1); + CHECK(vm.visibleTracks(tree, kDesignModeId).count("{F}") == 1); + + std::vector tracks{LaneTrack{"{F}", {}}}; // no own media + CHECK(planLaneMinting(vm, tree, tracks).empty()); +} + // -- D2.6 JSON round-trip with lane index + membership ----------------------- static void testLaneJsonRoundTrip() { @@ -1313,6 +1469,11 @@ int main() { testLaneMintingMultiModeMintsAndAssignsAll(); testLaneMintingManualLaneExempt(); testLaneMintingThreeModesAndOwnershipKeys(); + testLaneMintingFolderDerivedVisibleSplitsOwnMedia(); + testLaneMintingFolderOwnItemsSpanBothModes(); + testLaneMintingShowBothNotForceSplit(); + testLaneMintingSingleModeLeafVisibleOnceNoSplit(); + testLaneMintingEmptyFolderNotSplit(); testLaneJsonRoundTrip(); testLaneMalformedJson(); From 314cd7bf36f0931227071ad4f4984106993ba210 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Fri, 24 Jul 2026 05:44:19 -0400 Subject: [PATCH 19/22] Make tool-managed lane splits visually transparent: collapse display + lazy-mint Drive C_LANESCOLLAPSED=2 and hide lane buttons only on tracks the tool itself splits (gated on the pre-write I_FREEMODE read, so user comp-lane display prefs are never stomped). Lazy-mint managed lanes only for modes with own content, never an empty reserved lane; confinement holds via C_LANEPLAYS on the lone lane. --- src/view.cpp | 48 +++++++++++++++++++++++-- src/view_mode_model.cpp | 20 ++++++----- src/view_mode_model.h | 21 ++++++----- tests/test_view_mode_model.cpp | 65 +++++++++++++++++++++++++++++++--- 4 files changed, 131 insertions(+), 23 deletions(-) diff --git a/src/view.cpp b/src/view.cpp index fe7ca33..501e2d9 100644 --- a/src/view.cpp +++ b/src/view.cpp @@ -53,6 +53,39 @@ namespace { // positioning, 2=fixed lanes. constexpr int kFreeModeFixedLanes = 2; +// C_LANESCOLLAPSED display value (char*). SDK: 1=lanes collapsed, +// 2=track displays as non-fixed-lanes but hidden lanes exist. Value 2 is the lever that +// makes a tool-split track read like a NORMAL single-lane track showing only the playing +// lane — the inactive/silenced managed lanes are present but not drawn as separate rows. +constexpr int kLanesDisplayAsNormal = 2; + +// C_LANESETTINGS bit (char* bitmask). SDK: &32=hide lane buttons. We OR this in (never +// clobber the whole mask) to strip the per-lane button chrome from a tool-split track, so +// it reads as an ordinary track. We deliberately do NOT set &1 (auto-remove empty lanes at +// bottom): a managed lane whose item is later deleted would be silently removed out from +// under the ownership index. The lazy-mint decision already avoids ever minting an empty +// lane, so &1 buys nothing and risks a reconcile hazard. +constexpr int kLaneSettingsHideButtons = 32; + +// Drives a TOOL-SPLIT track's display transparent: C_LANESCOLLAPSED=2 (render like a normal +// single-lane track showing only the playing lane) + OR C_LANESETTINGS &32 (hide lane +// buttons). Both are char* params driven through the double API, same convention as +// C_LANEPLAYS:N. C_LANESETTINGS is read-modify-write so any pre-existing bit is preserved. +// +// MANAGED-VS-MANUAL BOUNDARY (load-bearing): these are TRACK-LEVEL settings that affect the +// whole track including a user's own manual comp lanes. Every caller gates this on the +// tool-driven transition INTO fixed lanes (freeMode != 2 before the flip), so a track the +// user already had in fixed-lane mode never reaches it and the user's comp-lane display +// prefs are never stomped. Idempotent: a re-run finds the track already at I_FREEMODE==2, +// the transition branch is skipped, and these writes do not fire again. +void applyTransparentLaneDisplay(MediaTrack* tr) { + SetMediaTrackInfo_Value(tr, "C_LANESCOLLAPSED", + static_cast(kLanesDisplayAsNormal)); + const int settings = static_cast(GetMediaTrackInfo_Value(tr, "C_LANESETTINGS")); + SetMediaTrackInfo_Value(tr, "C_LANESETTINGS", + static_cast(settings | kLaneSettingsHideButtons)); +} + // 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) { @@ -224,11 +257,16 @@ bool applyLaneOps(const std::vector>& handle // 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. + // that a timeline refresh is owed. Every track reaching this loop is already in the + // managed-lane ownership index (planToggle only emits ops for managed lanes), so a + // track here is one the TOOL split — a re-assert of fixed-lane mode is a tool-driven + // (re)split and must carry the same transparent display, mirroring applyMintPlan's + // transition branch. It is never a user's untouched manual-fixed-lane track. const int freeMode = static_cast(GetMediaTrackInfo_Value(tr, "I_FREEMODE")); if (freeMode != kFreeModeFixedLanes) { SetMediaTrackInfo_Value(tr, "I_FREEMODE", static_cast(kFreeModeFixedLanes)); + applyTransparentLaneDisplay(tr); // tool-managed track ⇒ read like a normal track touchedFreeMode = true; } @@ -371,10 +409,16 @@ bool applyMintPlan(ViewModeModel& model, const LaneMintPlan& plan, MediaTrack* tr = resolve(handleByGuid, split.trackGuid); if (!tr) continue; // stale GUID — prune - // Enable fixed-lane mode if not already (SDK: UpdateTimeline() owed after). + // Enable fixed-lane mode if not already (SDK: UpdateTimeline() owed after). The + // pre-write freeMode read is ALSO the managed-vs-manual boundary signal: a track that + // was NOT in fixed-lane mode here is one the TOOL is splitting now, so the tool owns + // its lane display and drives it transparent. A track already at I_FREEMODE==2 (user + // had fixed lanes, or a prior tool run) skips this branch — its C_LANESCOLLAPSED / + // C_LANESETTINGS are left exactly as the user set them. const int freeMode = static_cast(GetMediaTrackInfo_Value(tr, "I_FREEMODE")); if (freeMode != kFreeModeFixedLanes) { SetMediaTrackInfo_Value(tr, "I_FREEMODE", static_cast(kFreeModeFixedLanes)); + applyTransparentLaneDisplay(tr); // tool-split track ⇒ read like a normal track changed = true; } diff --git a/src/view_mode_model.cpp b/src/view_mode_model.cpp index 7052f57..56c361d 100644 --- a/src/view_mode_model.cpp +++ b/src/view_mode_model.cpp @@ -220,15 +220,19 @@ LaneMintPlan planLaneMinting(const ViewModeModel& model, const FolderTree& tree, // this is the load-bearing "don't lane-split single-mode tracks" rule. if (!multiMode) continue; - // Lanes to mint = the UNION of the modes the own items belong to and the modes the - // track is visible in. A folder whose own item is Design-only but which is derived- - // visible in Arrange too mints BOTH a Design lane (holding the item) and an Arrange - // lane (reserving Arrange's stance slot), matching "each mode owns a fixed lane." - std::set laneModes = ownItemModes; - if (visIt != visibleModesOf.end()) - laneModes.insert(visIt->second.begin(), visIt->second.end()); + // Lazy-mint: lanes to mint = ONLY the modes the track's OWN items actually occupy — + // never an empty reserved lane for a mode the track is merely derived-visible in. + // A folder whose own item is Design-only but which is derived-visible in Arrange too + // mints a Design lane ONLY (holding the item); it mints NO Arrange lane. Confinement + // still holds: with only a Design lane present, toggling to Arrange drives that lane's + // C_LANEPLAYS to 0 (it hides+silences) and no lane plays, so the track reads as an + // empty normal track — the Design item does not leak. The Arrange lane is minted on + // demand the moment an Arrange item first lands (a later mint tick sees ownItemModes + // gain Arrange). The visibility trigger above still decides WHETHER to split; it no + // longer inflates WHICH lanes are minted. + const std::set& laneModes = ownItemModes; - // Transition to lane-split: one managed lane per involved mode (durable key = + // Transition to lane-split: one managed lane per own-content mode (durable key = // laneNameForMode(mode)), owned by that mode. plan.splits.push_back(LaneMintPlan::TrackSplit{ track.trackGuid, static_cast(laneModes.size())}); diff --git a/src/view_mode_model.h b/src/view_mode_model.h index deffc33..b1daaaf 100644 --- a/src/view_mode_model.h +++ b/src/view_mode_model.h @@ -641,14 +641,19 @@ struct LaneMintPlan { // * A track visible in exactly ONE mode (single-mode leaf, single-mode folder) stays // whole-track-parked (D1) — NO split. This is the single-mode-track rule. // * On a split: one TrackSplit (laneCount == number of lanes to mint), one LaneMint per -// involved mode, and one LaneAssign per managed-eligible OWN item onto ITS tagged -// mode's lane — INCLUDING pre-existing items, so a folder carrying one own Design item -// while derived-visible in Arrange too still lanes that item to the Design lane (it -// then hides+silences whenever Arrange is active). The lanes minted are the union of: -// the modes the track's own items belong to, PLUS the modes the track is visible in — -// so a folder whose own item is Design-only but which is derived-visible in Arrange -// mints BOTH a Design lane (holding the item) and an Arrange lane (empty, reserving -// the Arrange stance's slot), matching "each mode owns a fixed lane." +// mode the track's OWN items occupy, and one LaneAssign per managed-eligible OWN item +// onto ITS tagged mode's lane — INCLUDING pre-existing items, so a folder carrying one +// own Design item while derived-visible in Arrange too still lanes that item to the +// Design lane (it then hides+silences whenever Arrange is active). +// * LAZY-MINT: lanes are minted ONLY for modes the track's own items actually occupy — +// never an empty reserved lane for a mode the track is merely derived-visible in. So a +// folder whose own item is Design-only but which is derived-visible in Arrange mints a +// Design lane ONLY (holding the item), NOT an empty Arrange lane. Confinement still +// holds: with only a Design lane present, toggling to Arrange drives that lane's +// C_LANEPLAYS to 0 (hide+silence) and no lane plays, so the track reads as an empty +// normal track and the Design item does not leak. The Arrange lane is minted on demand +// when an Arrange item first lands. The derived-visibility trigger still decides WHETHER +// to split; it no longer inflates WHICH lanes are minted. // // 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 diff --git a/tests/test_view_mode_model.cpp b/tests/test_view_mode_model.cpp index 5e3f5f5..b5e39c3 100644 --- a/tests/test_view_mode_model.cpp +++ b/tests/test_view_mode_model.cpp @@ -1235,9 +1235,11 @@ static void testLaneMintingThreeModesAndOwnershipKeys() { // The exact failing case. A folder {F} has descendant leaves in BOTH modes ({LD} Design, // {LA} Arrange) and carries ONE OWN item ({own}) tagged Design. W3-A's own-item-span test // alone would NOT split {F} (its own content is single-mode Design), so the item leaked -// into every mode the folder was derived-visible in. The visibility-aware decision now -// mints managed lanes on {F} and lanes {own} onto the Design lane — so it hides+silences -// whenever Arrange is active. This is the load-bearing fix; assert it hard. +// into every mode the folder was derived-visible in. The visibility-aware decision splits +// {F} and lanes {own} onto the Design lane — so it hides+silences whenever Arrange is +// active. LAZY-MINT: {F} mints ONLY the Design lane (holding the item), NOT an empty +// reserved Arrange lane — confinement holds via C_LANEPLAYS=0 on the lone Design lane when +// Arrange is active. This is the load-bearing fix; assert it hard. static void testLaneMintingFolderDerivedVisibleSplitsOwnMedia() { ViewModeModel vm; vm.membership().tag("{LD}", kDesignModeId); // a Design leaf under the folder @@ -1264,17 +1266,69 @@ static void testLaneMintingFolderDerivedVisibleSplitsOwnMedia() { // {F} MUST split even though its own item is single-mode: it is visible in 2 modes. CHECK(!plan.empty()); - CHECK(splitLaneCount(plan, "{F}") == 2); // one lane per stance + + // LAZY-MINT: ONE lane only — the Design lane that holds the item. No empty reserved + // Arrange lane is minted, even though {F} is derived-visible in Arrange. The Arrange + // lane appears on demand when an Arrange item first lands on {F}. + CHECK(splitLaneCount(plan, "{F}") == 1); // Design lane only — no reserved lane + CHECK(plan.mints.size() == 1); CHECK(hasMint(plan, "{F}", kDesignModeId)); // Design lane (holds the item) - CHECK(hasMint(plan, "{F}", kArrangeModeId)); // Arrange lane (reserves the slot) + CHECK(!hasMint(plan, "{F}", kArrangeModeId)); // NO empty reserved Arrange lane // The own item is confined to its tagged (Design) lane — the exact hide-in-Arrange fix. + // With only the Design lane present, toggling to Arrange sets its C_LANEPLAYS to 0, so + // the item hides+silences and the track reads as an empty normal track (no leak). CHECK(plan.assigns.size() == 1); CHECK(hasAssign(plan, "{own}", "{F}", kDesignModeId)); for (const auto& a : plan.assigns) CHECK(!(a.itemGuid == "{own}" && a.laneKey == laneNameForMode(kArrangeModeId))); } +// LAZY-MINT confinement proof. Same single-own-mode / dual-visibility folder, but instead +// of asserting the mint COUNT we prove the FUNCTIONAL confinement the lazy split preserves: +// apply the minted lane's ownership to a live model, then drive the toggle planner and show +// the lone Design lane SILENCES when Arrange is active (C_LANEPLAYS = 0). That is the whole +// point — a single managed lane still hides its item in every other mode, so removing the +// empty reserved Arrange lane costs nothing functionally. Fails if the split ever leaves the +// Design item audible in Arrange (the leak the D2 fix closed) or mints a spurious lane. +static void testLaneMintingLazySingleLaneStillConfines() { + ViewModeModel vm; + vm.membership().tag("{LD}", kDesignModeId); // Design leaf ⇒ folder visible in Design + // {LA} untagged ⇒ Arrange member ⇒ folder ALSO derived-visible in Arrange. + vm.membership().tag("{own}", kDesignModeId); // the folder's one own item (Design) + + FolderTree tree; + tree.nodes.push_back(FolderNode{"{F}", "", /*isParent=*/true}); + tree.nodes.push_back(FolderNode{"{LD}", "{F}", false}); + tree.nodes.push_back(FolderNode{"{LA}", "{F}", false}); + + std::vector tracks{ + LaneTrack{"{F}", {LaneItem{"{own}", kDesignModeId, false}}}, + }; + const LaneMintPlan plan = planLaneMinting(vm, tree, tracks); + + // Exactly one lane minted (the Design lane) — no empty reserved Arrange lane. + CHECK(plan.mints.size() == 1); + CHECK(hasMint(plan, "{F}", kDesignModeId)); + + // Apply the mint's ownership exactly as the shell does, then drive the toggle planner. + for (const auto& m : plan.mints) + CHECK(vm.lanes().setManaged(m.trackGuid, m.laneKey, m.modeId)); + CHECK(vm.lanes().size() == 1); // one managed lane on {F}, not two + + const std::string designLane = laneNameForMode(kDesignModeId); + + // Active = Design: the lone Design lane PLAYS (item visible+audible in its own mode). + const auto design = vm.planToggle(tree, kDesignModeId); + CHECK(lanePlaysFor(design, "{F}", designLane) == kLanePlaysExclusive); + + // Active = Arrange: the lone Design lane SILENCES — with no lane playing, the track + // reads as an empty normal track and the Design item does NOT leak. This is the + // confinement guarantee that lets us drop the reserved Arrange lane. + const auto arrange = vm.planToggle(tree, kArrangeModeId); + CHECK(lanePlaysFor(arrange, "{F}", designLane) == kLaneSilent); +} + // A folder carrying its OWN items that already span both modes → still split (the two // triggers OR: own-item span AND derived visibility both point the same way here). Both // own items separate to their tagged lanes. @@ -1470,6 +1524,7 @@ int main() { testLaneMintingManualLaneExempt(); testLaneMintingThreeModesAndOwnershipKeys(); testLaneMintingFolderDerivedVisibleSplitsOwnMedia(); + testLaneMintingLazySingleLaneStillConfines(); testLaneMintingFolderOwnItemsSpanBothModes(); testLaneMintingShowBothNotForceSplit(); testLaneMintingSingleModeLeafVisibleOnceNoSplit(); From 261efe545224c4d3d0ede73b4e22d173e31b5a08 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sat, 25 Jul 2026 13:30:07 -0400 Subject: [PATCH 20/22] feat(actions): item-level Design/Arrange move + untag actions; W3-A polish Add three MIDI-bindable item actions mirroring the track tag family, routed through the shared hookcommand: retag selected items' membership then re-drive mint/apply so each lands on its mode's managed lane (manual-lane items exempt), all in one undo block. Extract shared item_read seam, simplify applyMintPlan's lane-count pass, and guard reconcile against unregistered-mode lanes. --- CMakeLists.txt | 1 + src/actions.cpp | 118 ++++++++++++++++++++++++++++++++- src/bank_panel.cpp | 28 +------- src/item_read.cpp | 33 +++++++++ src/item_read.h | 34 ++++++++++ src/view.cpp | 51 +++++++------- src/view_mode_model.cpp | 12 ++++ src/view_mode_model.h | 44 ++++++++++++ tests/test_view_mode_model.cpp | 105 +++++++++++++++++++++++++++++ 9 files changed, 372 insertions(+), 54 deletions(-) create mode 100644 src/item_read.cpp create mode 100644 src/item_read.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 2b5fec5..864ea08 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -251,6 +251,7 @@ add_library(reaper_reasampler MODULE src/track_guid.cpp src/guid_diff.cpp src/lane_keys.cpp + src/item_read.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 wav_trim) diff --git a/src/actions.cpp b/src/actions.cpp index efb961f..7742ca1 100644 --- a/src/actions.cpp +++ b/src/actions.cpp @@ -24,9 +24,11 @@ #include #include +#include "item_read.h" // shared MediaItem* -> GUID + fixed-lane-name reads (D2 W3-B) +#include "lane_keys.h" // isOnManualLane — the single managed/manual predicate #include "persist.h" // ReaSamplerSession (owns view() model) #include "track_guid.h" // shared MediaTrack* -> canonical GUID key -#include "view.h" // applyMode (D2 shell) +#include "view.h" // applyMode + mintManagedLanes (D2 shell) #include "view_mode_model.h" #include "reaper_plugin.h" // reaper_plugin_info_t, gaccel_register_t (full defs) @@ -34,9 +36,15 @@ #define REAPERAPI_MINIMAL #define REAPERAPI_WANT_CountSelectedTracks #define REAPERAPI_WANT_GetSelectedTrack +#define REAPERAPI_WANT_CountSelectedMediaItems +#define REAPERAPI_WANT_GetSelectedMediaItem +#define REAPERAPI_WANT_GetMediaItemTrack +#define REAPERAPI_WANT_GetMediaTrackInfo_Value #define REAPERAPI_WANT_EnumProjects #define REAPERAPI_WANT_Main_SaveProject #define REAPERAPI_WANT_ShowConsoleMsg +#define REAPERAPI_WANT_Undo_BeginBlock2 +#define REAPERAPI_WANT_Undo_EndBlock2 #include "reaper_plugin_functions.h" namespace reasampler { @@ -53,6 +61,12 @@ constexpr const char* kIdTagDesign = "CEREBELLUM_REASAMPLER_VIEW_TAG_DESIGN"; constexpr const char* kIdTagArrange = "CEREBELLUM_REASAMPLER_VIEW_TAG_ARRANGE"; constexpr const char* kIdUntag = "CEREBELLUM_REASAMPLER_VIEW_UNTAG"; constexpr const char* kIdShowBoth = "CEREBELLUM_REASAMPLER_VIEW_SHOW_BOTH"; +// D2 Wave 3-B item-level mode moves — the item analog of the track tag family. Same +// FOREVER-STABLE contract: minted into a persistent command id, user keybindings key off +// each — NEVER change these strings after ship. +constexpr const char* kIdMoveItemsDesign = "CEREBELLUM_REASAMPLER_VIEW_MOVE_ITEMS_DESIGN"; +constexpr const char* kIdMoveItemsArrange = "CEREBELLUM_REASAMPLER_VIEW_MOVE_ITEMS_ARRANGE"; +constexpr const char* kIdUntagItems = "CEREBELLUM_REASAMPLER_VIEW_UNTAG_ITEMS"; // The live session the actions mutate. Set once by designViewRegisterActions and // read by the hookcommand handler. Not owned here (main.cpp owns g_session). @@ -66,6 +80,9 @@ int g_cmdTagDesign = 0; int g_cmdTagArrange = 0; int g_cmdUntag = 0; int g_cmdShowBoth = 0; +int g_cmdMoveItemsDesign = 0; +int g_cmdMoveItemsArrange = 0; +int g_cmdUntagItems = 0; // gaccel storage must outlive registration — REAPER holds each pointer until we // mirror-unregister it. One per action. @@ -76,6 +93,9 @@ gaccel_register_t g_accelTagDesign{}; gaccel_register_t g_accelTagArrange{}; gaccel_register_t g_accelUntag{}; gaccel_register_t g_accelShowBoth{}; +gaccel_register_t g_accelMoveItemsDesign{}; +gaccel_register_t g_accelMoveItemsArrange{}; +gaccel_register_t g_accelUntagItems{}; // Mints a command id from a stable string and registers its gaccel (Actions-list // entry with `desc`). Returns the command id (0 on failure). The gaccel storage is @@ -114,6 +134,37 @@ void reapplyActiveMode() { applyMode(g_session->view(), g_session->view().activeModeId(), nullptr); } +// Track fixed-lane mode value (I_FREEMODE=2). Mirrors the shell's constant; used only to +// decide whether an item's lane name is meaningful for the manual-lane read. +constexpr int kFreeModeFixedLanes = 2; + +// Collects the current media-item selection as the pure decision's input: each selected +// item's GUID plus whether it sits on a MANUAL lane (⇒ EXEMPT — never retagged/re-laned). +// The manual-lane read follows the shared pure predicate exactly as the shell's readers +// do: only on a fixed-lane track (I_FREEMODE==2) is the item's lane name read; on a normal +// track isOnManualLane returns false for the empty name, so the P_LANENAME read is skipped. +// Items whose GUID cannot be read are dropped (an empty GUID must never be retagged). +std::vector selectedRetagItems() { + std::vector items; + const int n = CountSelectedMediaItems(nullptr); // nullptr = active project + items.reserve(static_cast(n < 0 ? 0 : n)); + for (int i = 0; i < n; ++i) { + MediaItem* it = GetSelectedMediaItem(nullptr, i); + if (!it) continue; + std::string g = itemGuid(it); + if (g.empty()) continue; + + MediaTrack* tr = GetMediaItemTrack(it); + const bool fixedLane = + tr && static_cast(GetMediaTrackInfo_Value(tr, "I_FREEMODE")) == kFreeModeFixedLanes; + // Only read the lane name on a fixed-lane track; the pure predicate handles the + // normal-track case (returns false) so we pass an empty name and skip the read. + const std::string laneNm = fixedLane ? itemLaneName(tr, it) : std::string{}; + items.push_back(RetagItem{std::move(g), isOnManualLane(fixedLane, laneNm)}); + } + return items; +} + // Persists both the bank and the Design-View model to the active project's ext // state. Called after every state-changing Design View action so the view model // is not lost across save/close/reopen. Marking the project dirty is correct — @@ -212,6 +263,48 @@ void doShowBoth() { persistViewState(); } +// -- Item-level mode moves (D2 Wave 3-B) ----------------------------------- +// +// Retag the current ITEM selection to `targetMode` (empty ⇒ untag → Arrange default), +// then re-drive the minting + apply path so each moved item lands on its target mode's +// managed lane and the active-mode lane visibility is reasserted. The pure planItemRetag +// decides which selected items to retag (manual-lane items are EXEMPT — never retagged, +// never re-laned), upholding the managed-lanes-only invariant even under this explicit +// user action. The whole structural act is wrapped in ONE Undo block with a descriptive +// label (the inner blocks mintManagedLanes / applyMode open nest harmlessly under it). +// +// UNDO/SAVE ordering: persistViewState may pop a Save-As dialog (Main_SaveProject) which +// must NOT sit inside the Undo block, so we close the block first, then persist — the same +// separation the track actions rely on (they persist outside applyMode's own block). +void doMoveItems(const std::string& targetMode) { + const std::vector selected = selectedRetagItems(); + const std::vector ops = planItemRetag(selected, targetMode); + if (ops.empty()) return; // nothing selected, or every selected item was exempt/empty + + MembershipIndex& membership = g_session->view().membership(); + + Undo_BeginBlock2(nullptr); + // Apply the pure decision's membership writes: tag into targetMode, or untag. + for (const ItemRetagOp& op : ops) { + if (op.untag) membership.untag(op.guid); + else membership.tag(op.guid, op.modeId); + } + // Re-drive the SAME minting/apply path auto-tag uses: mint/split lanes for any track + // whose items now span modes and assign each moved item to its mode's managed lane, + // then reassert the active mode's lane visibility. Manual lanes stay untouched + // (mintManagedLanes reports their items exempt and never mints over them). + mintManagedLanes(g_session->view(), nullptr); + reapplyActiveMode(); + + const std::string label = + targetMode.empty() + ? std::string("ReaSampler: untag selected items") + : std::string("ReaSampler: move selected items -> ") + targetMode; + Undo_EndBlock2(nullptr, label.c_str(), -1); + + persistViewState(); +} + } // namespace void designViewRegisterActions(reaper_plugin_info_t* rec, ReaSamplerSession* session) { @@ -233,6 +326,14 @@ void designViewRegisterActions(reaper_plugin_info_t* rec, ReaSamplerSession* ses "ReaSampler: untag selected tracks"); g_cmdShowBoth = registerAction(rec, kIdShowBoth, g_accelShowBoth, "ReaSampler: show both for selected tracks"); + + // Item-level mode moves (D2 W3-B): the item analog of the track tag family. + g_cmdMoveItemsDesign = registerAction(rec, kIdMoveItemsDesign, g_accelMoveItemsDesign, + "ReaSampler: move selected items -> Design"); + g_cmdMoveItemsArrange = registerAction(rec, kIdMoveItemsArrange, g_accelMoveItemsArrange, + "ReaSampler: move selected items -> Arrange"); + g_cmdUntagItems = registerAction(rec, kIdUntagItems, g_accelUntagItems, + "ReaSampler: untag selected items"); } bool designViewHandleCommand(int command) { @@ -247,12 +348,25 @@ bool designViewHandleCommand(int command) { if (command == g_cmdUntag) { doUntag(); return true; } if (command == g_cmdShowBoth) { doShowBoth(); return true; } + // Item-level moves. Move -> Arrange and Untag items collapse to the same act (an + // empty target ⇒ untag ⇒ Arrange default), mirroring the track-level pairing above. + if (command == g_cmdMoveItemsDesign) { doMoveItems(kDesignModeId); return true; } + if (command == g_cmdMoveItemsArrange) { doMoveItems(std::string{}); return true; } + if (command == g_cmdUntagItems) { doMoveItems(std::string{}); return true; } + return false; // not ours — caller's hookcommand keeps looking } void designViewUnregisterActions(reaper_plugin_info_t* rec) { // Mirror-unregister with '-'-prefixed strings, per the contract's unload rule. - // gaccel first, then the command_id string (reverse of registration order). + // gaccel first, then the command_id string (reverse of registration order — the item + // moves registered last, so they tear down first). + rec->Register("-gaccel", (void*)&g_accelUntagItems); + rec->Register("-command_id", (void*)kIdUntagItems); + rec->Register("-gaccel", (void*)&g_accelMoveItemsArrange); + rec->Register("-command_id", (void*)kIdMoveItemsArrange); + rec->Register("-gaccel", (void*)&g_accelMoveItemsDesign); + rec->Register("-command_id", (void*)kIdMoveItemsDesign); rec->Register("-gaccel", (void*)&g_accelShowBoth); rec->Register("-command_id", (void*)kIdShowBoth); rec->Register("-gaccel", (void*)&g_accelUntag); diff --git a/src/bank_panel.cpp b/src/bank_panel.cpp index 6e68684..d6a07a2 100644 --- a/src/bank_panel.cpp +++ b/src/bank_panel.cpp @@ -42,6 +42,7 @@ #include "bank_model.h" #include "capture_paths.h" #include "guid_diff.h" // GuidBaseline — new-content detection (D2 Wave 2) +#include "item_read.h" // itemGuid / itemLaneName — shared item-read seam (D2 W3-B) #include "lane_keys.h" // managed/manual lane heuristic (D2 Wave 2) #include "mode_switch.h" #include "peaks.h" @@ -86,11 +87,8 @@ #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. @@ -684,28 +682,8 @@ bool isFixedLaneTrack(MediaTrack* tr) { return static_cast(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(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); -} +// Item GUID + fixed-lane name reads come from the shared item_read seam (item_read.h): +// itemGuid(it) and itemLaneName(tr, it). bank_panel.cpp no longer carries its own copies. // 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 diff --git a/src/item_read.cpp b/src/item_read.cpp new file mode 100644 index 0000000..40edc65 --- /dev/null +++ b/src/item_read.cpp @@ -0,0 +1,33 @@ +// item_read.cpp — the single MediaItem* read seam (GUID + fixed-lane name). See +// item_read.h. Compiled into the reaper_reasampler MODULE; includes +// reaper_plugin_functions.h WITHOUT REAPERAPI_IMPLEMENT (main.cpp is the one TU that +// defines the API pointers — CLAUDE.md §contract). + +#include "item_read.h" + +#include + +#define REAPERAPI_MINIMAL +#define REAPERAPI_WANT_GetSetMediaItemInfo_String +#define REAPERAPI_WANT_GetMediaItemInfo_Value +#define REAPERAPI_WANT_GetSetMediaTrackInfo_String +#include "reaper_plugin_functions.h" + +namespace reasampler { + +std::string itemGuid(MediaItem* it) { + char buf[64] = {0}; + if (!GetSetMediaItemInfo_String(it, "GUID", buf, false)) return {}; + return std::string(buf); +} + +std::string itemLaneName(MediaTrack* tr, MediaItem* it) { + 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); +} + +} // namespace reasampler diff --git a/src/item_read.h b/src/item_read.h new file mode 100644 index 0000000..7bb74e9 --- /dev/null +++ b/src/item_read.h @@ -0,0 +1,34 @@ +#pragma once +// item_read — the ONE place a MediaItem* is read for its canonical GUID string and for +// the durable P_LANENAME of the fixed lane it sits on. Before this seam, view.cpp and +// bank_panel.cpp each carried a near-identical private itemGuid / itemLaneName pair +// (both files' comments acknowledged the deliberate copy); the D2 Wave-3-B item actions +// need the same two reads, so the duplication is extracted here — the item-read analog +// of track_guid's single MediaTrack* -> GUID-key formatter. +// +// REAPER-facing shell: the .cpp includes reaper_plugin_functions.h WITHOUT +// REAPERAPI_IMPLEMENT (main.cpp owns the API pointers; here they are extern — +// CLAUDE.md §contract). MediaItem / MediaTrack are forward-declared so this header +// stays SDK-lite. These are shell reads (REAPER string/value getters); the managed/ +// manual DECISION that consumes the lane name stays pure in lane_keys (isOnManualLane). + +#include + +class MediaItem; +class MediaTrack; + +namespace reasampler { + +// An item's canonical GUID string via GetSetMediaItemInfo_String("GUID"). Empty on a +// read failure (an empty GUID must never be tagged — every caller skips empties). +std::string itemGuid(MediaItem* it); + +// The durable P_LANENAME of the fixed lane item `it` currently sits on (read via the +// item's I_FIXEDLANE ordinal, then P_LANENAME:n on `tr`). Empty if the lane is unnamed +// or the param is unavailable. Callers must already know `tr` is a fixed-lane track +// (I_FREEMODE==2) before calling — I_FIXEDLANE is meaningless otherwise; the pure +// isOnManualLane predicate handles the non-fixed-lane case via its own argument, so +// callers should not call this at all for a normal track. +std::string itemLaneName(MediaTrack* tr, MediaItem* it); + +} // namespace reasampler diff --git a/src/view.cpp b/src/view.cpp index 501e2d9..aac2c10 100644 --- a/src/view.cpp +++ b/src/view.cpp @@ -18,6 +18,7 @@ #include #include +#include "item_read.h" #include "lane_keys.h" #include "track_guid.h" #include "view_tree.h" @@ -42,7 +43,6 @@ #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 { @@ -289,21 +289,8 @@ bool applyLaneOps(const std::vector>& handle // 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); -} +// Item GUID + fixed-lane name reads come from the shared item_read seam (item_read.h): +// itemGuid(it) and itemLaneName(tr, it). view.cpp no longer carries its own copies. // 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 @@ -314,7 +301,7 @@ std::map itemHandlesByGuid(MediaTrack* tr) { for (int i = 0; i < itemCount; ++i) { MediaItem* it = GetTrackMediaItem(tr, i); if (!it) continue; - std::string ig = itemGuidString(it); + std::string ig = itemGuid(it); if (!ig.empty()) byGuid.emplace(std::move(ig), it); } return byGuid; @@ -354,7 +341,7 @@ std::vector readLaneTracks( for (int i = 0; i < itemCount; ++i) { MediaItem* it = GetTrackMediaItem(tr, i); if (!it) continue; - const std::string ig = itemGuidString(it); + const std::string ig = itemGuid(it); if (ig.empty()) continue; LaneItem li; li.guid = ig; @@ -362,7 +349,7 @@ std::vector readLaneTracks( // 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{}; + const std::string ln = fixedLane ? itemLaneName(tr, it) : std::string{}; li.onManualLane = isOnManualLane(fixedLane, ln); lt.items.push_back(std::move(li)); } @@ -425,24 +412,23 @@ bool applyMintPlan(ViewModeModel& model, const LaneMintPlan& plan, // 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")); + // laneCount tracks the live I_NUMFIXEDLANES as we grow it: read ONCE here, then + // each mint appends at laneCount and bumps it. No per-mint I_NUMFIXEDLANES re-read + // is needed — nextOrdinal and laneCount are the same running value. + int laneCount = 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)); - } + // Append at the current tail ordinal, grow the tracked count, stamp its name. + const int laneIdx = laneCount++; + SetMediaTrackInfo_Value(tr, "I_NUMFIXEDLANES", static_cast(laneCount)); char parm[32]; std::snprintf(parm, sizeof(parm), "P_LANENAME:%d", laneIdx); std::vector name(m->laneKey.begin(), m->laneKey.end()); @@ -658,6 +644,17 @@ void reconcileManagedLanes(ViewModeModel& model, ReaProject* proj) { if (!key) continue; // manual/unnamed lane — leave off the index std::optional mode = modeIdFromLaneName(name); if (!mode) continue; // prefix-only/illegal name — skip defensively + + // UNREGISTERED-MODE GUARD: the durable name encodes a mode id, but that mode + // may no longer be a registered Mode (e.g. a mode removed from the registry + // after the project was saved with lanes minted for it). Recording it MANAGED + // would make the toggle planner drive a lane keyed to a mode that can never be + // the active mode — the lane would stay silenced+hidden forever, orphaning its + // items with no way for the user to reach them. So we do NOT record it: the + // lane is left off the ownership index and thus treated as manual-by-default + // (never driven). Its durable name is preserved on the track, so if the mode is + // ever re-registered a later reconcile recovers the ownership cleanly. + if (!model.modes().contains(*mode)) continue; model.lanes().setManaged(guid, *key, *mode); } } diff --git a/src/view_mode_model.cpp b/src/view_mode_model.cpp index 56c361d..7528e1a 100644 --- a/src/view_mode_model.cpp +++ b/src/view_mode_model.cpp @@ -158,6 +158,18 @@ std::vector autoTagNewContent(const std::vector& newTrackG return tags; } +std::vector planItemRetag(const std::vector& selected, + const std::string& targetMode) { + std::vector ops; + const bool untag = targetMode.empty(); // empty target ⇒ untag (→ Arrange default) + for (const RetagItem& item : selected) { + if (item.guid.empty()) continue; // defensive; a real item always has a GUID + if (item.onManualLane) continue; // manual-lane item is EXEMPT — never retagged + ops.push_back(ItemRetagOp{item.guid, untag, untag ? std::string{} : targetMode}); + } + return ops; +} + // --------------------------------------------------------------------------- // lane minting decision // --------------------------------------------------------------------------- diff --git a/src/view_mode_model.h b/src/view_mode_model.h index b1daaaf..42445f5 100644 --- a/src/view_mode_model.h +++ b/src/view_mode_model.h @@ -519,6 +519,50 @@ std::vector autoTagNewContent(const std::vector& newTrackG const std::vector& newItems, const std::string& activeMode); +// -- Item-level mode-move decision (Phase D2 / Wave 3-B) --------------------- +// +// The bindable item actions (Move selected items -> Design / -> Arrange / Untag) +// retag the CURRENT item selection's membership, then re-drive the minting/apply +// path so each moved item lands on its target mode's managed lane. The DECISION — +// which selected items to retag, and to what — is pure and unit-tested here; the +// shell only reads the item selection (GUID + manual-lane disposition) and applies +// the resulting membership writes + re-lane pass. +// +// MANAGED-LANES-ONLY INVARIANT (upheld at the source, exactly as auto-tag does): an +// item the shell reports as already on a MANUAL lane is EXEMPT — it is never retagged, +// never untagged, never re-laned. The tool drives only what it minted, even under an +// explicit user action. The shell reports `onManualLane` per item and this decision +// emits NO op for such items; the shell then skips them entirely. + +// One selected item the shell reports for the retag decision: its GUID and whether it +// currently sits on a MANUAL lane (⇒ EXEMPT: no membership change, no re-lane). +struct RetagItem { + std::string guid; + bool onManualLane = false; // true ⇒ EXEMPT from the item mode-move actions +}; + +// One membership op the item mode-move decision produced for one selected item. `untag` +// true ⇒ remove the item from the index (return it to the Arrange default); otherwise +// tag it into `modeId`. The shell applies each verbatim to the MembershipIndex. +struct ItemRetagOp { + std::string guid; + bool untag = false; // true ⇒ untag; false ⇒ tag into modeId + std::string modeId; // the target mode when !untag (empty when untag) + + bool operator==(const ItemRetagOp& o) const { + return guid == o.guid && untag == o.untag && modeId == o.modeId; + } +}; + +// The pure item mode-move decision: given the selected items and a target mode, produce +// the membership ops. An EMPTY `targetMode` means UNTAG (the "Untag selected items" and +// "Move -> Arrange" actions collapse to the same act — Arrange is the absence of a tag, +// mirroring the track-level doUntag). A non-empty `targetMode` tags each eligible item +// into it. Manual-lane items are skipped (no op emitted); items with an empty GUID are +// skipped (defensive). The function mutates nothing — it returns a plan the shell applies. +std::vector planItemRetag(const std::vector& selected, + const std::string& targetMode); + // -- Lane minting decision (Phase D2 / Wave 3) ------------------------------- // // D1 parks a whole track when it holds content of only ONE mode. The moment a track diff --git a/tests/test_view_mode_model.cpp b/tests/test_view_mode_model.cpp index b5e39c3..2e98388 100644 --- a/tests/test_view_mode_model.cpp +++ b/tests/test_view_mode_model.cpp @@ -1074,6 +1074,109 @@ static void testAutoTagDecision() { } } +// -- D2 W3-B item-level mode-move decision ----------------------------------- +// +// planItemRetag: the pure decision behind the three item actions. A non-empty target +// tags each eligible selected item into it; an EMPTY target untags (→ Arrange default). +// Manual-lane items are EXEMPT (no op) and empty-GUID items are skipped. + +// Find the single op for `guid`, or nullptr. +static const ItemRetagOp* retagOpFor(const std::vector& ops, + const std::string& guid) { + for (const auto& o : ops) + if (o.guid == guid) return &o; + return nullptr; +} + +static void testPlanItemRetag() { + // Move -> Design: a managed-lane / normal item is tagged into Design (untag=false). + { + std::vector sel{ + RetagItem{"{A}", /*onManualLane=*/false}, + RetagItem{"{B}", false}, + }; + auto ops = planItemRetag(sel, kDesignModeId); + CHECK(ops.size() == 2); + const ItemRetagOp* a = retagOpFor(ops, "{A}"); + CHECK(a != nullptr); + CHECK(a && !a->untag); // a tag, not an untag + CHECK(a && a->modeId == kDesignModeId); // into Design specifically + const ItemRetagOp* b = retagOpFor(ops, "{B}"); + CHECK(b && !b->untag && b->modeId == kDesignModeId); + } + + // Empty target ⇒ UNTAG each item (Move -> Arrange / Untag items collapse to this). + // untag must be true and modeId empty — NOT a tag into "arrange". + { + std::vector sel{ RetagItem{"{A}", false} }; + auto ops = planItemRetag(sel, std::string{}); + CHECK(ops.size() == 1); + const ItemRetagOp* a = retagOpFor(ops, "{A}"); + CHECK(a != nullptr); + CHECK(a && a->untag); // an untag + CHECK(a && a->modeId.empty()); // no target mode carried on an untag + } + + // Manual-lane exemption: a manual-lane item yields NO op — not for Move nor for Untag. + { + std::vector sel{ + RetagItem{"{NORMAL}", false}, + RetagItem{"{MANUAL}", true}, // on a hand-managed lane ⇒ EXEMPT + }; + auto design = planItemRetag(sel, kDesignModeId); + CHECK(design.size() == 1); + CHECK(retagOpFor(design, "{NORMAL}") != nullptr); + CHECK(retagOpFor(design, "{MANUAL}") == nullptr); // exempt — never retagged + + auto untag = planItemRetag(sel, std::string{}); + CHECK(untag.size() == 1); + CHECK(retagOpFor(untag, "{NORMAL}") != nullptr); + CHECK(retagOpFor(untag, "{MANUAL}") == nullptr); // exempt — never untagged + } + + // Empty-GUID items are skipped defensively; empty selection ⇒ no ops. + { + std::vector sel{ RetagItem{"", false}, RetagItem{"{A}", false} }; + auto ops = planItemRetag(sel, kDesignModeId); + CHECK(ops.size() == 1); + CHECK(retagOpFor(ops, "{A}") != nullptr); + + CHECK(planItemRetag({}, kDesignModeId).empty()); + CHECK(planItemRetag({}, std::string{}).empty()); + } +} + +// -- D2 W3-B reconcile unregistered-mode guard (pure decision) --------------- +// +// reconcileManagedLanes (shell) recovers a lane's managed ownership from its durable +// name, but must NOT record ownership for a mode the registry no longer knows — a lane +// keyed to an unregistered mode can never become the active mode's lane and would stay +// silenced+hidden forever, orphaning its items. The guard's pure decision is exactly +// modeIdFromLaneName(name) ∈ modes(): this locks that composition so the shell's guard +// (which calls model.modes().contains(*mode)) cannot silently drift. + +static void testReconcileUnregisteredModeGuardDecision() { + ViewModeModel vm; // seeds Arrange + Design only + + // A managed lane naming a REGISTERED mode: mode decodes and IS contained ⇒ record. + { + const std::string name = laneNameForMode(kDesignModeId); + auto mode = modeIdFromLaneName(name); + CHECK(mode.has_value()); + CHECK(vm.modes().contains(*mode)); // guard passes ⇒ shell records ownership + } + + // A managed lane naming an UNREGISTERED mode: mode decodes but is NOT contained ⇒ + // the guard rejects it and the shell leaves the lane off the index (manual-by-default). + { + const std::string name = laneNameForMode("removed_mode"); + auto mode = modeIdFromLaneName(name); + CHECK(mode.has_value()); + CHECK(*mode == "removed_mode"); + CHECK(!vm.modes().contains(*mode)); // guard fails ⇒ shell must skip + } +} + // -- D2.7 Lane minting decision (Wave 3) ------------------------------------- // // planLaneMinting: a track with content of only ONE mode is NOT split (D1 unchanged); @@ -1519,6 +1622,8 @@ int main() { testLaneOwnershipLastWriterWins(); testManagedOnlyPlannerAndQuery(); testAutoTagDecision(); + testPlanItemRetag(); + testReconcileUnregisteredModeGuardDecision(); testLaneMintingSingleModeNoSplit(); testLaneMintingMultiModeMintsAndAssignsAll(); testLaneMintingManualLaneExempt(); From a786915451ebff4d40653f86a54c784bc4693829 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sat, 25 Jul 2026 14:12:12 -0400 Subject: [PATCH 21/22] docs: record D2-W3-B landing; mark Phase D2 complete; preserve deferred panel indicator Move D2-W3-B (item-level mode actions + W3-A polish) from PLAN.md to COMPLETED.md. Phase D2 two-canvas sub-phase functionally complete; panel UI indicator explicitly deferred, not dropped. --- COMPLETED.md | 40 ++++++++++++++++++++++++++++++++++++++++ PLAN.md | 34 ++++++++-------------------------- 2 files changed, 48 insertions(+), 26 deletions(-) diff --git a/COMPLETED.md b/COMPLETED.md index deef77d..72542b4 100644 --- a/COMPLETED.md +++ b/COMPLETED.md @@ -614,3 +614,43 @@ flips to fixed-lane mode, and whether the leftover empty default lane 0 is silen closed with no label (discarded by REAPER) when the plan is non-empty but every write was already satisfied, so no phantom undo points accumulate. - **Review passed** with no Critical or Major findings. + +--- + +## D2-W3-B — item-level mode actions + W3-A polish +**Goal:** Item-level lane/mode-management actions mirroring the track-level Design +View tag family (bindable in the Actions list), plus the three code-review polish +items carried from D2-W3-A. The persist slice and lane-ownership index round-trip +were completed in D2-W3-A; this wave closes the remaining action surface and +cleans up the implementation. +See CONTEXT.md §Two-canvas sub-phase (Module architecture — persistence). +**Verify (in DAW):** Item mode actions registered and MIDI-bindable in the Actions +list; re-drive mint/apply so each item lands on its mode's managed lane; manual-lane +items exempt; one undo block per action. ctest 14/14 green. +**Depends on:** D2-W3-A. + +- [x] "Move selected items → Design" action (`CEREBELLUM_REASAMPLER_VIEW_` family): + retags selected items' membership to Design mode, re-drives the existing mint/apply + so each item lands on its mode's managed lane; manual-lane items exempt; one undo + block. +- [x] "Move selected items → Arrange" action: retags selected items' membership to + Arrange mode, re-drives mint/apply; manual-lane items exempt; one undo block. +- [x] "Untag selected items" action: removes selected items' membership, re-drives + mint/apply; manual-lane items exempt; one undo block. +- [x] All three registered (`command_id`/`gaccel`/`hookcommand`); MIDI-bindable. +- [x] W3-A polish — simplified `applyMintPlan`'s redundant `I_NUMFIXEDLANES` re-read: + single grow-and-track pass removes the second `GetMediaTrackInfo_Value` call inside + the mint loop. +- [x] W3-A polish — extracted shared item-read seam (`src/item_read.{h,cpp}`): + removes duplicated `itemGuid`/`itemLaneName` read logic from `view.cpp` and + `bank_panel.cpp`. +- [x] W3-A polish — added reconcile guard in `reconcileManagedLanes`: skips lanes + encoding an unregistered mode id (log and skip rather than silently recording an + orphaned ownership entry). + +**Notes/decisions:** +- ctest 14/14 green; review passed with no Critical or Major findings. +- **Panel UI indicator explicitly deferred** (Daniel's decision): a per-track + lane-split marker has no natural cheap home in the bank panel; the mode switch + already shows the active mode. Preserved as a deferred/backlog note in PLAN.md + Phase D2 — not silently dropped. diff --git a/PLAN.md b/PLAN.md index 4273d95..12a08ab 100644 --- a/PLAN.md +++ b/PLAN.md @@ -119,32 +119,14 @@ landed milestone. > point lands, doc-keeper moves it to `COMPLETED.md`. > > **D2-W1 (pure lane extension), D2-W2 (shell: lane application + new-content -> detection), and D2-W3-A (lane minting + item→lane assignment + persist -> round-trip) have landed** — see `COMPLETED.md`. - -## D2-W3-B — two-canvas actions + panel UI indicators -**Goal:** Any new lane/mode-management actions (bindable in the Actions list), and -any panel UI indicator for lane/mode state. The persist slice and the -lane-ownership index round-trip were completed in D2-W3-A; this wave closes the -remaining UI and action surface. -See CONTEXT.md §Two-canvas sub-phase (Module architecture — persistence). -**Verify (in DAW):** Lane/mode-management actions are registered and bindable in -the Actions list; the panel UI indicator reflects the current lane/mode state. -**Depends on:** D2-W3-A. - -- [ ] Any new lane/mode-management actions (`command_id`/`gaccel`/`hookcommand`); - bindable in the Actions list. -- [ ] Any panel UI indicator for lane/mode state. - -**D2-W3-A code-review polish (fold into this wave):** -- [ ] Simplify the redundant per-mint `I_NUMFIXEDLANES` re-read in `view.cpp` - (`applyMintPlan`): a single grow-and-track pass removes the second - `GetMediaTrackInfo_Value` call inside the mint loop. -- [ ] Optional shared item-read helper to remove duplicated `itemGuid`/`itemLaneName` - read logic between `view.cpp` and `bank_panel.cpp`. -- [ ] Optional defensive note/check in `reconcileManagedLanes` for the edge case - where a managed lane name encodes an unregistered mode id (log and skip rather - than silently record an orphaned ownership entry). +> detection), D2-W3-A (lane minting + item→lane assignment + persist round-trip), +> and D2-W3-B (item-level mode actions + W3-A polish) have all landed** — see +> `COMPLETED.md`. **Phase D2 is functionally complete.** +> +> **Deferred:** panel UI indicator for per-track lane/mode state (a per-track +> lane-split marker). The mode switch already shows the active mode; no natural +> cheap home for a per-track indicator was found in the bank panel. Explicitly +> deferred — not silently dropped. Can be picked up later if wanted. --- From 47721a40c4529e4244c00d440f25707bbe6dc091 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sat, 25 Jul 2026 17:16:58 -0400 Subject: [PATCH 22/22] fix(view): redraw arrange when inserted item lands on already-split lane An item inserted onto an already-split folder in Design mode sat on the correct lane but stayed invisible until a manual toggle: mintManagedLanes returned on the idempotent no-op path before its UpdateArrange call. Force the redraw there so the item appears immediately. --- src/view.cpp | 22 ++++++++++++++--- tests/test_view_mode_model.cpp | 45 ++++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 4 deletions(-) diff --git a/src/view.cpp b/src/view.cpp index aac2c10..a6c4321 100644 --- a/src/view.cpp +++ b/src/view.cpp @@ -595,11 +595,25 @@ bool mintManagedLanes(ViewModeModel& model, ReaProject* 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. + // The plan was non-empty but every REAPER write was already satisfied. 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); + + // BUT the arrange still needs a redraw. On the detect-tick caller (bankPanelRefresh) + // mintManagedLanes runs only when this tick just tagged new content, and a NON-EMPTY + // plan means that content sits on a managed-split track. The idempotent no-op path is + // reached when a freshly-inserted item ALREADY landed on the active mode's playing + // lane (REAPER places a new item on the playing lane; the active mode's lane IS the + // playing lane, so assignItemToLane sees I_FIXEDLANE unchanged and writes nothing). + // The item is correctly placed and confined, but the arrange was never told to + // repaint it onto the lane — so it stayed invisible until a manual mode toggle forced + // applyMode's refresh. Force the redraw here so the item appears immediately without a + // toggle. UpdateArrange() only repaints (no I_FREEMODE transition happened on this + // path, so UpdateTimeline is not owed); it is NOT a project mutation, so it stays + // outside the undo block and adds no history entry. On the action caller (doMoveItems) + // this is a harmless repaint immediately before its own reapplyActiveMode() refresh. + UpdateArrange(); return false; } diff --git a/tests/test_view_mode_model.cpp b/tests/test_view_mode_model.cpp index 2e98388..a659b0e 100644 --- a/tests/test_view_mode_model.cpp +++ b/tests/test_view_mode_model.cpp @@ -1459,6 +1459,50 @@ static void testLaneMintingFolderOwnItemsSpanBothModes() { CHECK(hasAssign(plan, "{d}", "{F}", kDesignModeId)); } +// -- Fix (pd2): item inserted onto an ALREADY-split folder still yields a non-empty plan -- +// +// Regression guard for the "inserted item invisible until a manual toggle" bug. When a new +// item lands (via insert/capture) on a folder that is ALREADY lane-split and derived-visible +// in >1 mode, and REAPER placed it on the active mode's currently-playing lane, the shell's +// assignItemToLane sees I_FIXEDLANE unchanged and writes nothing — applyMintPlan reports +// changed==false. The shell must STILL treat this tick as "content landed on a managed track" +// and refresh the arrange (so the item draws immediately, no toggle). The pure signal the +// shell keys on is: planLaneMinting returns a NON-EMPTY plan carrying an assign for the new +// item. This test locks that signal; if planLaneMinting ever went empty here, the shell would +// have nothing to refresh on and the bug would return. +static void testLaneMintingNewItemOnAlreadySplitFolderYieldsPlan() { + ViewModeModel vm; + vm.membership().tag("{LD}", kDesignModeId); // Design leaf ⇒ folder derived-visible Design + // {LA} untagged ⇒ Arrange ⇒ folder ALSO derived-visible in Arrange (dual-visible). + vm.membership().tag("{own}", kDesignModeId); // the pre-existing own Design item + vm.membership().tag("{new}", kDesignModeId); // the JUST-INSERTED item (auto-tagged Design) + + FolderTree tree; + tree.nodes.push_back(FolderNode{"{F}", "", /*isParent=*/true}); + tree.nodes.push_back(FolderNode{"{LD}", "{F}", false}); + tree.nodes.push_back(FolderNode{"{LA}", "{F}", false}); + + // The folder is already split for Design (its lane exists + is owned). This mirrors the + // live "already auto-split" track the bug reproduces on. + CHECK(vm.lanes().setManaged("{F}", laneNameForMode(kDesignModeId), kDesignModeId)); + + // {F} now carries its original own item PLUS the freshly-inserted one, both Design. + std::vector tracks{ + LaneTrack{"{F}", { + LaneItem{"{own}", kDesignModeId, false}, + LaneItem{"{new}", kDesignModeId, false}, + }}, + }; + const LaneMintPlan plan = planLaneMinting(vm, tree, tracks); + + // The plan is NON-EMPTY (folder is dual-visible ⇒ splits) and carries an assign for the + // new item onto the Design lane. In the shell this is the exact branch that must force a + // redraw even when the assign is an idempotent no-op (item already on the playing lane). + CHECK(!plan.empty()); + CHECK(hasAssign(plan, "{new}", "{F}", kDesignModeId)); + CHECK(hasAssign(plan, "{own}", "{F}", kDesignModeId)); +} + // SHOW-BOTH escape hatch: a show-both track carrying its own items is visible in every // mode ON PURPOSE and must NOT be force-split — its content stays cross-mode-visible. // Even with own items that would otherwise span modes, the decision skips it entirely. @@ -1631,6 +1675,7 @@ int main() { testLaneMintingFolderDerivedVisibleSplitsOwnMedia(); testLaneMintingLazySingleLaneStillConfines(); testLaneMintingFolderOwnItemsSpanBothModes(); + testLaneMintingNewItemOnAlreadySplitFolderYieldsPlan(); testLaneMintingShowBothNotForceSplit(); testLaneMintingSingleModeLeafVisibleOnceNoSplit(); testLaneMintingEmptyFolderNotSplit();