Merge dev into phase-b-multibank (integrate parallel M7/8 + Phase D work before dev promotion)

# Conflicts:
#	CLAUDE.md
#	CMakeLists.txt
#	src/actions.cpp
#	src/bank_panel.cpp
#	src/persist.h
This commit is contained in:
2026-07-25 23:30:44 -04:00
42 changed files with 5289 additions and 140 deletions
+312 -11
View File
@@ -40,6 +40,8 @@
#include <cstdint>
#include <cstdlib> // std::abs (drag threshold)
#include <filesystem>
#include <map>
#include <set>
#include <string>
#include <unordered_map>
#include <vector>
@@ -48,12 +50,17 @@
#include "bank_grid.h"
#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"
#include "persist.h"
#include "tab_strip.h"
#include "tail_control.h" // TailSetting, cycleTailMode, tailToggleLabel (pure)
#include "track_guid.h" // guidString — canonical track GUID key (D2 Wave 2)
#include "view.h" // applyMode — the D2/D4 mode-activation entrypoint the switch fires
#include "view_mode_model.h" // autoTagNewContent / NewItem (D2 Wave 2)
// SWELL / LICE. On macOS/Linux SWELL is provided by the host (SWELL_PROVIDED_BY_APP);
// on Windows we use native Win32 (windows.h first, then swell.h no-ops on _WIN32).
@@ -76,9 +83,20 @@
#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
// 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_CountTrackMediaItems
#define REAPERAPI_WANT_GetTrackMediaItem
// 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.
#define REAPERAPI_WANT_PlayPreview
#define REAPERAPI_WANT_StopPreview
#define REAPERAPI_WANT_GetUserInputs
@@ -226,13 +244,43 @@ struct PanelState {
std::string dropBankId; // destination bank id when dropKind==Tab
// --- Tail-mode toggle -----------------------------------------------------
TailSetting tail;
// 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 -----------------------------------------------------
preview_register_t preview{};
PCM_source* previewSrc = nullptr;
bool previewActive = false;
bool previewInited = 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 (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;
bool reloadPending = false; // set by bankPanelNotifyProjectLoaded; drained next detect tick
};
PanelState g_panel;
@@ -381,8 +429,10 @@ void drawThumbnail(LICE_IBitmap* bmp, const CellRect& rect, const Envelope& env,
const int innerW = rect.width - 4;
for (int i = 0; i < nbins; ++i) {
const int x = rect.x + 2 + (nbins > 1 ? (i * (innerW - 1)) / (nbins - 1) : 0);
int yMax = midY - static_cast<int>(bins[i].max * halfSpan);
int yMin = midY - static_cast<int>(bins[i].min * halfSpan);
// 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<int>(compressAmplitudeForDisplay(bins[i].max) * halfSpan); // max -> up
int yMin = midY - static_cast<int>(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);
@@ -456,6 +506,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 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;
@@ -465,7 +524,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;
SetTextColor(dc, kRgbFooterText);
@@ -474,6 +533,30 @@ 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;
}
// 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);
}
// --- Split geometry -----------------------------------------------------------
//
// Every rect below is derived from the client size + fullHeight state, and BOTH paint
@@ -851,7 +934,146 @@ bool refreshFingerprint() {
return true;
}
// --- Audition preview (unchanged from M5) -------------------------------------
// --- New-content detection (D2 Wave 2) ----------------------------------------
//
// REAPER exposes no "item/track added" callback, so we diff live project state on the
// existing timer. Each tick: enumerate every track GUID and every item GUID, diff
// against the previous tick (GuidBaseline, first-poll-guarded), and auto-tag the new
// GUIDs into the active mode via the pure autoTagNewContent. An item on a MANUAL lane
// is exempt (design point #1) — its lane's durable name lacks the managed prefix. All
// enumeration is READ-ONLY on the project; the only mutation is to the in-memory
// membership index (persisted by persist on the next save, same as an action-driven tag).
// True iff `tr` has I_FREEMODE==2 (fixed lanes enabled). The SDK value is verified
// in view.cpp (kFreeModeFixedLanes=2); reproduced here as a local constant so
// bank_panel.cpp stays self-contained without pulling in view.cpp's private namespace.
constexpr int kFreeModeFixedLanes = 2;
bool isFixedLaneTrack(MediaTrack* tr) {
return static_cast<int>(GetMediaTrackInfo_Value(tr, "I_FREEMODE")) == kFreeModeFixedLanes;
}
// 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
// newly-detected item can be exempted from auto-tag without a second project walk.
//
// Manual-lane classification uses the single pure predicate isOnManualLane(isFixedLaneTrack,
// laneName) from lane_keys — the same predicate the apply path consults — so the exemption
// rule is defined in exactly one place and is unit-tested there.
void enumerateLiveGuids(ReaProject* proj, std::set<std::string>& allGuids,
std::map<std::string, bool>& itemOnManualLane) {
const int trackCount = CountTracks(proj);
for (int t = 0; t < trackCount; ++t) {
MediaTrack* tr = GetTrack(proj, t);
if (!tr) continue;
std::string tg = guidString(tr);
if (!tg.empty()) allGuids.insert(tg);
// Compute the fixed-lane status once per track (not per item) — I_FREEMODE is a
// track-level attribute and is the same for every item on the track.
const bool fixedLane = isFixedLaneTrack(tr);
const int itemCount = CountTrackMediaItems(tr);
for (int i = 0; i < itemCount; ++i) {
MediaItem* it = GetTrackMediaItem(tr, i);
if (!it) continue;
std::string ig = itemGuid(it);
if (ig.empty()) continue;
allGuids.insert(ig);
// Classify via the single shared predicate. For a fixed-lane track we read
// the item's lane name; for a normal track we pass "" (isOnManualLane returns
// false immediately for non-fixed-lane tracks regardless of name).
const std::string ln = fixedLane ? itemLaneName(tr, it) : std::string{};
itemOnManualLane[ig] = isOnManualLane(fixedLane, ln);
}
}
}
// One detection tick: diff live GUIDs against the baseline and auto-tag the new ones
// into the active mode. Runs every timer tick regardless of panel open/close (content
// is created in the arrange). READ-ONLY on the project; mutates only the in-memory
// membership index.
//
// INTENTIONAL: membership mutation happens OUTSIDE any Undo block. Auto-tag is a
// background metadata update (like setting a label), not a destructive project edit.
// persist.cpp writes it on the next project save alongside the bank and view state, the
// same way an action-driven tag is persisted. Wrapping this in an Undo block would flood
// the REAPER undo history with a new entry for every timer tick that sees new content.
// 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);
// 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.reloadPending = false;
}
std::set<std::string> live;
std::map<std::string, bool> itemOnManualLane;
enumerateLiveGuids(proj, live, itemOnManualLane);
const std::vector<std::string> added = g_panel.contentBaseline.observe(live);
if (added.empty()) return 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
// item; otherwise it is a track (track GUIDs never appear in that map).
std::vector<std::string> newTracks;
std::vector<NewItem> newItems;
for (const std::string& g : added) {
auto it = itemOnManualLane.find(g);
if (it == itemOnManualLane.end()) {
newTracks.push_back(g); // a track GUID
} else {
newItems.push_back(NewItem{g, it->second}); // an item; carries its exemption
}
}
ViewModeModel& model = g_panel.session->view();
const std::vector<AutoTag> tags =
autoTagNewContent(newTracks, newItems, model.activeModeId());
for (const AutoTag& tag : tags)
model.membership().tag(tag.guid, tag.modeId);
return !tags.empty();
}
// --- Audition preview ---------------------------------------------------------
//
// READ-ONLY / NON-DESTRUCTIVE (load-bearing principle): audition is PREVIEW
// playback only. It NEVER inserts into the arrange, creates items/tracks, or
// mutates the project or bank. PlayPreview streams a caller-owned PCM_source
// through REAPER's preview bus and touches nothing in the project.
//
// FLAGGED RUNTIME ASSUMPTIONS (header does not specify these; verified only by
// signature/struct, not semantics — DAW-verify):
// 1. REAPER's audio thread reads the preview_register_t by POINTER while the
// preview is active (the struct's own comment mandates a cs/mutex we init),
// so the register must outlive playback — we hold it in g_panel (static),
// never on the stack.
// 2. StopPreview is assumed to detach the source from the audio thread BEFORE it
// returns, making it safe to PCM_Source_Destroy the source immediately after.
// This is the conventional contract (SWS' preview helpers rely on it) but is
// NOT documented in the header — flagged. If a rare race surfaced, the fix is
// a StartPreviewFade + deferred free; not done now (YAGNI, no evidence).
// 3. m_out_chan == 0 routes to the first hardware output pair (stereo). We do not
// set mono (&1024). volume 1.0, loop false, curpos 0.
void initPreview() {
if (g_panel.previewInited) return;
@@ -1298,10 +1520,15 @@ void handleClick(int x, int y) {
}
}
// Tail footer: a click anywhere cycles the tail mode.
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);
// Tail footer: a click anywhere in the bottom strip cycles the tail mode
// (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;
}
@@ -1370,6 +1597,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 a region's current grid width (nav needs the layout's wrap).
int columnsForRegion(Region reg) {
RECT cr{};
@@ -1597,6 +1852,19 @@ WDL_DLGRET dlgProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) {
invalidatePanel();
}
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<short>(HIWORD(wParam));
return handleWheel(pt.x, pt.y, delta) ? 1 : 0;
}
case WM_DESTROY:
if (GetCapture() == hwnd) ReleaseCapture();
stopAudition();
@@ -1677,14 +1945,47 @@ std::string bankPanelSelectedSourceBankId() {
return id.empty() ? std::string(kPoolBankId) : id;
}
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
// 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).
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;
if (refreshFingerprint())
InvalidateRect(g_panel.hwnd, nullptr, FALSE);
}
TailSetting bankPanelTailSetting() {
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;
}