Ψ-W1-T2: disjoint per-mode solo surfaces and a playback-gated mode switch
Solo is cached, cleared and replayed per mode on a real switch only; the switch is refused visibly while the transport runs. The footer segment now routes through the activate actions, so a panel switch finally persists.
This commit is contained in:
+15
-7
@@ -3,12 +3,13 @@
|
||||
## Scope
|
||||
|
||||
Pure, REAPER-free Design View model: mode/track membership, folder-derived
|
||||
visibility, snapshot-based park/restore planning, new-content (GUID) detection,
|
||||
and the managed/manual lane-identity convention that underlies per-item mode
|
||||
separation (fixed lanes). Does **not** include: the actual DAW-side flag
|
||||
application (hide, CPU-park, per-FX offline, restore via `B_SHOWINTCP` /
|
||||
`B_SHOWINMIXER` / `B_MAINSEND` / `I_FXEN`) or the never-touch-master/mute/solo
|
||||
enforcement — those live in `shell/view`.
|
||||
visibility, snapshot-based park/restore planning, the per-mode solo cache and its
|
||||
replay plan, new-content (GUID) detection, and the managed/manual lane-identity
|
||||
convention that underlies per-item mode separation (fixed lanes). Does **not**
|
||||
include: the actual DAW-side flag application (hide, CPU-park, per-FX offline,
|
||||
restore via `B_SHOWINTCP` / `B_SHOWINMIXER` / `B_MAINSEND` / `I_FXEN`, solo
|
||||
cache/clear/replay via `I_SOLO`) or the never-touch-master/mute enforcement —
|
||||
those live in `shell/view`.
|
||||
|
||||
## Invariants
|
||||
|
||||
@@ -33,6 +34,12 @@ settled 2026-07-23):
|
||||
from the snapshot, never to a hardcoded default. Round-trip (snapshot → park →
|
||||
restore) returns every driven flag to its captured value — this is the
|
||||
phase's trust anchor, the analog of the capture null test.
|
||||
- **Disjoint solo surfaces, cached not destroyed.** Solo is per mode: a real
|
||||
switch banks the outgoing mode's raw `I_SOLO` values, clears them, and replays
|
||||
the incoming mode's verbatim. Same snapshot sense of non-destructive as the
|
||||
bullet above — the tool never *loses* the user's solo, it parks it with the mode
|
||||
it belongs to. `B_MUTE` and the master track stay untouched absolutely. A
|
||||
reapply touches solo not at all.
|
||||
- **GUID-keyed, reorder-safe.** Membership keys on track GUID (`GetTrackGUID`),
|
||||
never track index; tolerates unknown/stale GUIDs (pruned on reconcile via
|
||||
`ViewModeModel::reconcile(liveGuids)`).
|
||||
@@ -84,7 +91,8 @@ settled 2026-07-23):
|
||||
|
||||
## Modules
|
||||
|
||||
- `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.
|
||||
- `view_mode_model` — Design View mode system: mode registry, GUID-keyed membership, folder-tree-aware visibility derivation, snapshot-based park/restore planner, the per-mode `SoloCache` it owns, JSON round-trip.
|
||||
- `solo_cache` — the per-mode solo surface: `SoloCache` (mode id → GUID → raw `I_SOLO`), the soloed-subset filter, and `planSoloRestore`, whose two drop rules (dead GUID, track parked in the incoming mode) and their reasoning live in its header.
|
||||
- `view_tree` — pure `I_FOLDERDEPTH`→FolderTree helper for the Design View shell.
|
||||
- `mode_switch` — REAPER-free segment layout + hit-test for the bank_panel's Design View mode switch.
|
||||
- `guid_diff` — the pure, REAPER-free core of the D2 Wave-2 new-content detection: `newGuids(previous, current)` computes the GUIDs present in `current` but absent from `previous` (empty GUIDs ignored); `GuidBaseline` tracks the live GUID set across polls for one project, implementing the first-poll-after-open guard (the first `observe()` after construction/`reset()` records a baseline and reports nothing new, so pre-existing content is never mass-tagged) and re-arms via `reset()` on a detected project switch so detection never diffs across two unrelated projects.
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
reasampler_pure_library(lane_keys SOURCES lane_keys.cpp)
|
||||
reasampler_test(lane_keys LINK lane_keys)
|
||||
|
||||
# lane_keys is PUBLIC: the lane-minting plan names managed lanes through the one durable-key
|
||||
# convention, so every consumer has to resolve that symbol too.
|
||||
reasampler_pure_library(solo_cache SOURCES solo_cache.cpp)
|
||||
reasampler_test(solo_cache LINK solo_cache)
|
||||
|
||||
# lane_keys and solo_cache are PUBLIC: the lane-minting plan names managed lanes through the
|
||||
# one durable-key convention, and ViewModeModel exposes the SoloCache by reference, so every
|
||||
# consumer has to resolve those symbols too.
|
||||
reasampler_pure_library(view_mode_model
|
||||
SOURCES view_mode_model.cpp
|
||||
LINK PRIVATE json PUBLIC lane_keys)
|
||||
LINK PRIVATE json PUBLIC lane_keys solo_cache)
|
||||
reasampler_test(view_mode_model LINK view_mode_model)
|
||||
|
||||
reasampler_pure_library(view_tree SOURCES view_tree.cpp LINK PUBLIC view_mode_model)
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
// solo_cache — pure implementation. See solo_cache.h.
|
||||
|
||||
#include "core/view/solo_cache.h"
|
||||
|
||||
namespace reasampler::view {
|
||||
|
||||
std::map<std::string, int> soloedTracks(const std::vector<TrackSolo>& live) {
|
||||
std::map<std::string, int> soloed;
|
||||
for (const TrackSolo& t : live) {
|
||||
if (t.guid.empty() || t.solo == 0) continue;
|
||||
soloed.emplace(t.guid, t.solo); // first reading wins if a GUID repeats
|
||||
}
|
||||
return soloed;
|
||||
}
|
||||
|
||||
bool SoloCache::store(const std::string& modeId, const std::map<std::string, int>& soloed) {
|
||||
if (modeId.empty()) return false;
|
||||
if (soloed.empty()) {
|
||||
byMode_.erase(modeId);
|
||||
return true;
|
||||
}
|
||||
byMode_[modeId] = soloed;
|
||||
return true;
|
||||
}
|
||||
|
||||
const std::map<std::string, int>* SoloCache::query(const std::string& modeId) const {
|
||||
auto it = byMode_.find(modeId);
|
||||
return it == byMode_.end() ? nullptr : &it->second;
|
||||
}
|
||||
|
||||
bool SoloCache::clear(const std::string& modeId) {
|
||||
return byMode_.erase(modeId) > 0;
|
||||
}
|
||||
|
||||
std::size_t SoloCache::reconcile(const std::set<std::string>& liveGuids) {
|
||||
std::size_t removed = 0;
|
||||
for (auto mode = byMode_.begin(); mode != byMode_.end();) {
|
||||
for (auto entry = mode->second.begin(); entry != mode->second.end();) {
|
||||
if (liveGuids.count(entry->first) == 0) {
|
||||
entry = mode->second.erase(entry);
|
||||
++removed;
|
||||
} else {
|
||||
++entry;
|
||||
}
|
||||
}
|
||||
// A mode emptied by pruning must not survive as an empty record — same
|
||||
// reason store() drops one (see header).
|
||||
if (mode->second.empty()) mode = byMode_.erase(mode);
|
||||
else ++mode;
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
std::vector<SoloOp> planSoloRestore(const std::map<std::string, int>& cached,
|
||||
const std::set<std::string>& liveGuids,
|
||||
const std::set<std::string>& parkedGuids) {
|
||||
std::vector<SoloOp> ops;
|
||||
for (const auto& [guid, value] : cached) {
|
||||
if (liveGuids.count(guid) == 0) continue;
|
||||
if (parkedGuids.count(guid) != 0) continue;
|
||||
ops.push_back(SoloOp{guid, value});
|
||||
}
|
||||
return ops;
|
||||
}
|
||||
|
||||
} // namespace reasampler::view
|
||||
@@ -0,0 +1,86 @@
|
||||
#pragma once
|
||||
// Per-mode solo surface: the cache of raw I_SOLO values a real mode switch banks on
|
||||
// the way out and replays on the way back, plus the two decisions over it. Pure —
|
||||
// the GetMediaTrackInfo_Value/SetMediaTrackInfo_Value pair is shell/view/view_solo.
|
||||
// Values are the RAW I_SOLO int, never collapsed to a bool: the SDK's domain is
|
||||
// 0=off, 1=solo, 2=solo-in-place, 5=safe solo, 6=safe solo-in-place, and all four
|
||||
// non-zero variants must survive the round trip.
|
||||
|
||||
#include <cstddef>
|
||||
#include <map>
|
||||
#include <set>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace reasampler::view {
|
||||
|
||||
// One live (track GUID, I_SOLO) reading from the shell's enumeration.
|
||||
struct TrackSolo {
|
||||
std::string guid;
|
||||
int solo = 0;
|
||||
};
|
||||
|
||||
// One I_SOLO write the shell must apply.
|
||||
struct SoloOp {
|
||||
std::string guid;
|
||||
int value = 0;
|
||||
|
||||
bool operator==(const SoloOp& o) const { return guid == o.guid && value == o.value; }
|
||||
};
|
||||
|
||||
// The soloed subset of a live enumeration. Zero is the resting value — a track at
|
||||
// zero is neither cached (nothing to replay) nor cleared (nothing to undo), so a
|
||||
// project with no solo anywhere produces no cache entry and no project write at
|
||||
// all. Empty GUIDs are dropped: they can never resolve back to a track.
|
||||
std::map<std::string, int> soloedTracks(const std::vector<TrackSolo>& live);
|
||||
|
||||
// mode id -> (track GUID -> raw I_SOLO). Lifecycle mirrors the park/restore
|
||||
// snapshots: stored on the way out of a mode, consumed on the way back in, pruned
|
||||
// when a GUID stops existing.
|
||||
class SoloCache {
|
||||
public:
|
||||
// Replaces `modeId`'s entry. An EMPTY set removes it rather than storing an
|
||||
// empty record — otherwise a serialized cache would parse back to a model that
|
||||
// differs from its source, breaking the model's round-trip contract.
|
||||
bool store(const std::string& modeId, const std::map<std::string, int>& soloed);
|
||||
|
||||
const std::map<std::string, int>* query(const std::string& modeId) const;
|
||||
|
||||
bool clear(const std::string& modeId);
|
||||
|
||||
const std::map<std::string, std::map<std::string, int>>& all() const { return byMode_; }
|
||||
|
||||
bool empty() const { return byMode_.empty(); }
|
||||
|
||||
// Drops every cached GUID absent from `liveGuids`, and any mode left empty.
|
||||
// Returns the number of GUID entries removed.
|
||||
//
|
||||
// Pruned like the snapshots and unlike membership: a cached solo is a captured
|
||||
// prior value awaiting replay onto one specific track, so a stale entry
|
||||
// surviving a delete would replay onto whatever track later reuses that GUID —
|
||||
// soloing a track the user never soloed and silencing the rest of the mix.
|
||||
// The cost is the mirror case: undoing a track delete restores the GUID but not
|
||||
// its cached solo. One lost solo the user can see and re-click beats an
|
||||
// inexplicable mix-wide mute.
|
||||
std::size_t reconcile(const std::set<std::string>& liveGuids);
|
||||
|
||||
bool operator==(const SoloCache& o) const { return byMode_ == o.byMode_; }
|
||||
|
||||
private:
|
||||
std::map<std::string, std::map<std::string, int>> byMode_;
|
||||
};
|
||||
|
||||
// The incoming mode's restore writes, in GUID order. Two kinds of entry are dropped
|
||||
// rather than written:
|
||||
// * a GUID absent from `liveGuids` — the track is gone (same prune rule as above);
|
||||
// * a GUID parked in the incoming mode — a parked track is hidden and carries
|
||||
// B_MAINSEND=0, so soloing it would silence the whole mix while contributing
|
||||
// nothing audible, and the user would have no visible control to undo it.
|
||||
// The caller consumes the whole mode entry regardless (clear-on-restore), so a
|
||||
// dropped entry does not linger as zombie state waiting on a track that may never
|
||||
// come back unparked.
|
||||
std::vector<SoloOp> planSoloRestore(const std::map<std::string, int>& cached,
|
||||
const std::set<std::string>& liveGuids,
|
||||
const std::set<std::string>& parkedGuids);
|
||||
|
||||
} // namespace reasampler::view
|
||||
@@ -247,6 +247,8 @@ const TrackSnapshot* ViewModeModel::snapshot(const std::string& guid) const {
|
||||
|
||||
std::size_t ViewModeModel::reconcile(const std::set<std::string>& liveGuids) {
|
||||
// See header: snapshots are pruned, membership is not (undo-delete rationale).
|
||||
soloCache_.reconcile(liveGuids);
|
||||
|
||||
std::size_t removed = 0;
|
||||
for (auto it = snapshots_.begin(); it != snapshots_.end();) {
|
||||
if (liveGuids.count(it->first) == 0) {
|
||||
@@ -347,7 +349,8 @@ std::set<LaneRef> ViewModeModel::lanesTouchedByToggle() const {
|
||||
|
||||
bool ViewModeModel::operator==(const ViewModeModel& o) const {
|
||||
return modes_ == o.modes_ && membership_ == o.membership_ && lanes_ == o.lanes_ &&
|
||||
activeModeId_ == o.activeModeId_ && snapshots_ == o.snapshots_;
|
||||
activeModeId_ == o.activeModeId_ && snapshots_ == o.snapshots_ &&
|
||||
soloCache_ == o.soloCache_;
|
||||
}
|
||||
|
||||
namespace {
|
||||
@@ -443,6 +446,31 @@ std::string ViewModeModel::serialize() const {
|
||||
}
|
||||
}
|
||||
out += ']';
|
||||
|
||||
// soloCache: array of { mode, tracks: [ { guid, solo } ] }
|
||||
root.keyBegin("soloCache");
|
||||
out += '[';
|
||||
{
|
||||
bool firstMode = true;
|
||||
for (const auto& [modeId, byGuid] : soloCache_.all()) {
|
||||
if (!firstMode) out += ',';
|
||||
firstMode = false;
|
||||
ObjWriter m(out);
|
||||
m.keyStr("mode", modeId);
|
||||
m.keyBegin("tracks");
|
||||
out += '[';
|
||||
bool firstTrack = true;
|
||||
for (const auto& [guid, solo] : byGuid) {
|
||||
if (!firstTrack) out += ',';
|
||||
firstTrack = false;
|
||||
ObjWriter e(out);
|
||||
e.keyStr("guid", guid);
|
||||
e.keyRaw("solo", intToStr(solo));
|
||||
}
|
||||
out += ']';
|
||||
}
|
||||
}
|
||||
out += ']';
|
||||
} // root closes here (NRVO + deferred close, mirrors bank_model)
|
||||
return out;
|
||||
}
|
||||
@@ -569,6 +597,59 @@ bool parseLanes(json::Reader& r, LaneOwnershipIndex& idx) {
|
||||
return r.consume(']');
|
||||
}
|
||||
|
||||
// One mode's cached solo set. Strict like parseLanes: every key the writer emits is
|
||||
// mandatory and non-empty. An empty tracks array is rejected — serialize never emits
|
||||
// one (store drops an empty set), so accepting it would let a hand-edited blob parse
|
||||
// into a model that re-serializes differently.
|
||||
bool parseSoloTracks(json::Reader& r, std::map<std::string, int>& byGuid) {
|
||||
if (!r.consume('[')) return false;
|
||||
r.skipWs();
|
||||
if (r.consume(']')) return true;
|
||||
do {
|
||||
if (!r.consume('{')) return false;
|
||||
std::string guid;
|
||||
int solo = 0;
|
||||
bool haveGuid = false, haveSolo = false;
|
||||
do {
|
||||
std::string k;
|
||||
if (!r.parseKey(k)) return false;
|
||||
if (k == "guid") { if (!r.parseString(guid)) return false; haveGuid = true; }
|
||||
else if (k == "solo") { if (!r.parseInt(solo)) return false; haveSolo = true; }
|
||||
else if (!r.skipValue()) return false;
|
||||
} while (r.consume(','));
|
||||
if (!r.consume('}')) return false;
|
||||
if (!haveGuid || !haveSolo || guid.empty()) return false;
|
||||
byGuid[guid] = solo;
|
||||
} while (r.consume(','));
|
||||
return r.consume(']');
|
||||
}
|
||||
|
||||
bool parseSoloCache(json::Reader& r, view::SoloCache& cache) {
|
||||
if (!r.consume('[')) return false;
|
||||
r.skipWs();
|
||||
if (r.consume(']')) return true;
|
||||
do {
|
||||
if (!r.consume('{')) return false;
|
||||
std::string modeId;
|
||||
std::map<std::string, int> byGuid;
|
||||
bool haveMode = false, haveTracks = false;
|
||||
do {
|
||||
std::string k;
|
||||
if (!r.parseKey(k)) return false;
|
||||
if (k == "mode") { if (!r.parseString(modeId)) return false; haveMode = true; }
|
||||
else if (k == "tracks") {
|
||||
if (!parseSoloTracks(r, byGuid)) return false;
|
||||
haveTracks = true;
|
||||
}
|
||||
else if (!r.skipValue()) return false;
|
||||
} while (r.consume(','));
|
||||
if (!r.consume('}')) return false;
|
||||
if (!haveMode || modeId.empty() || !haveTracks || byGuid.empty()) return false;
|
||||
if (!cache.store(modeId, byGuid)) return false;
|
||||
} while (r.consume(','));
|
||||
return r.consume(']');
|
||||
}
|
||||
|
||||
bool parseModel(json::Reader& r, ViewModeModel& out) {
|
||||
if (!r.consume('{')) return false;
|
||||
r.skipWs();
|
||||
@@ -581,6 +662,7 @@ bool parseModel(json::Reader& r, ViewModeModel& out) {
|
||||
MembershipIndex membership;
|
||||
LaneOwnershipIndex lanes;
|
||||
std::map<std::string, TrackSnapshot> snaps;
|
||||
view::SoloCache soloCache;
|
||||
|
||||
do {
|
||||
std::string key;
|
||||
@@ -599,6 +681,8 @@ bool parseModel(json::Reader& r, ViewModeModel& out) {
|
||||
if (!parseSnapshots(r, snaps)) return false;
|
||||
} else if (key == "lanes") {
|
||||
if (!parseLanes(r, lanes)) return false;
|
||||
} else if (key == "soloCache") {
|
||||
if (!parseSoloCache(r, soloCache)) return false;
|
||||
} else {
|
||||
if (!r.skipValue()) return false; // unknown keys / "version" placeholder
|
||||
}
|
||||
@@ -611,6 +695,7 @@ bool parseModel(json::Reader& r, ViewModeModel& out) {
|
||||
if (haveModes) out.modes() = reg;
|
||||
out.membership() = membership;
|
||||
out.lanes() = lanes;
|
||||
out.soloCache() = soloCache;
|
||||
for (const auto& [guid, snap] : snaps) out.storeSnapshot(guid, snap);
|
||||
if (haveActive) {
|
||||
if (!out.setActiveMode(activeMode)) return false; // active mode must exist
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "core/view/solo_cache.h"
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
// Stable seed-mode ids. Arrange is the default home for untagged leaves.
|
||||
@@ -291,6 +293,8 @@ public:
|
||||
const MembershipIndex& membership() const { return membership_; }
|
||||
LaneOwnershipIndex& lanes() { return lanes_; }
|
||||
const LaneOwnershipIndex& lanes() const { return lanes_; }
|
||||
view::SoloCache& soloCache() { return soloCache_; }
|
||||
const view::SoloCache& soloCache() const { return soloCache_; }
|
||||
|
||||
const std::string& activeModeId() const { return activeModeId_; }
|
||||
// Returns false (no change) if the id is not registered.
|
||||
@@ -302,8 +306,9 @@ public:
|
||||
const TrackSnapshot* snapshot(const std::string& guid) const;
|
||||
const std::map<std::string, TrackSnapshot>& snapshots() const { return snapshots_; }
|
||||
|
||||
// Drops every snapshot whose GUID is NOT in `liveGuids`. Returns the count
|
||||
// removed.
|
||||
// Drops every snapshot whose GUID is NOT in `liveGuids`, and prunes the solo
|
||||
// cache the same way (see SoloCache::reconcile). Returns the count of
|
||||
// SNAPSHOTS removed — the solo cache's own count is available from it directly.
|
||||
//
|
||||
// Snapshots are pruned, membership is not: a parked track's snapshot is
|
||||
// dead weight once the track is deleted (can never restore; a reused GUID
|
||||
@@ -355,6 +360,7 @@ private:
|
||||
LaneOwnershipIndex lanes_; // (guid, laneKey) -> ownership
|
||||
std::string activeModeId_; // always a registered id
|
||||
std::map<std::string, TrackSnapshot> snapshots_; // guid -> pre-park snapshot
|
||||
view::SoloCache soloCache_; // modeId -> guid -> raw I_SOLO
|
||||
};
|
||||
|
||||
// Fixed-zero park plan for one leaf, offlining `fxCount` slots.
|
||||
|
||||
Reference in New Issue
Block a user