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
+116 -2
View File
@@ -26,9 +26,11 @@
#include "bank_book.h" // BankBook, nextBankId, TransferResult, kPoolBankId (B1)
#include "bank_panel.h" // selection seam + full-height toggles (B3/B4)
#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 book() + 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)
@@ -36,6 +38,10 @@
#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
@@ -43,6 +49,8 @@
#define REAPERAPI_WANT_ShowMessageBox
#define REAPERAPI_WANT_genGuid
#define REAPERAPI_WANT_guidToString
#define REAPERAPI_WANT_Undo_BeginBlock2
#define REAPERAPI_WANT_Undo_EndBlock2
#include "reaper_plugin_functions.h"
namespace reasampler {
@@ -59,6 +67,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).
@@ -72,6 +86,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.
@@ -82,6 +99,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
@@ -120,6 +140,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<RetagItem> selectedRetagItems() {
std::vector<RetagItem> items;
const int n = CountSelectedMediaItems(nullptr); // nullptr = active project
items.reserve(static_cast<std::size_t>(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<int>(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 —
@@ -218,6 +269,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<RetagItem> selected = selectedRetagItems();
const std::vector<ItemRetagOp> 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) {
@@ -239,6 +332,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) {
@@ -253,12 +354,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);
+25
View File
@@ -3,6 +3,7 @@
#include "bank_grid.h"
#include <algorithm>
#include <cmath>
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
+20
View File
@@ -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
+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;
}
+11
View File
@@ -65,6 +65,17 @@ std::string bankPanelSelectedSourceBankId();
// 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
+182 -5
View File
@@ -71,13 +71,18 @@
#include <chrono>
#include <cstdint>
#include <cstring>
#include <ctime>
#include <filesystem>
#include <fstream>
#include <string>
#include <vector>
#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<std::uint8_t> 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<std::uint8_t> bytes(static_cast<std::size_t>(size));
f.seekg(0);
f.read(reinterpret_cast<char*>(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<std::uint8_t>& bytes, std::size_t off, std::uint32_t v) {
bytes[off + 0] = static_cast<std::uint8_t>(v & 0xFF);
bytes[off + 1] = static_cast<std::uint8_t>((v >> 8) & 0xFF);
bytes[off + 2] = static_cast<std::uint8_t>((v >> 16) & 0xFF);
bytes[off + 3] = static_cast<std::uint8_t>((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<std::uint8_t> 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<std::size_t>(
rangeSeconds * static_cast<double>(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<reasampler::AudioSample> tailPcm =
extractFloatFrames(bytes, layout, rangeEndFrame, tailFrames);
if (tailPcm.empty()) return kNoTrim;
const float threshold = static_cast<float>(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<const char*>(bytes.data()),
static_cast<std::streamsize>(plan.newFileByteLength));
if (!out) return kNoTrim;
out.close();
// The trimmed length in seconds for the Sample metadata.
return static_cast<double>(keptFrames) / static_cast<double>(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_))
+44
View File
@@ -0,0 +1,44 @@
// guid_diff implementation — pure set arithmetic for new-content detection. See
// guid_diff.h. No REAPER, no SWELL — std only.
#include "guid_diff.h"
#include <algorithm>
namespace reasampler {
std::vector<std::string> newGuids(const std::set<std::string>& previous,
const std::set<std::string>& current) {
std::vector<std::string> added;
// current \ previous. std::set iterates ascending, so set_difference yields a
// deterministic order without a separate sort.
for (const std::string& g : current) {
if (g.empty()) continue; // never tag a GUID-read failure
if (previous.count(g) == 0) added.push_back(g);
}
return added;
}
std::vector<std::string> GuidBaseline::observe(const std::set<std::string>& current) {
if (!primed_) {
// First poll after open/reset: establish the baseline, report nothing new so
// pre-existing content is NOT auto-tagged (it defaults to Arrange).
baseline_ = current;
primed_ = true;
return {};
}
std::vector<std::string> added = newGuids(baseline_, current);
// Advance the baseline to the full current set. Using `current` (not baseline_
// added) means a DELETED GUID drops out of the baseline too, so if REAPER later
// reuses that GUID for genuinely new content it is detected again — the baseline
// tracks the live set exactly, not a monotonic union.
baseline_ = current;
return added;
}
void GuidBaseline::reset() {
baseline_.clear();
primed_ = false; // next observe() re-baselines (first-poll guard re-armed)
}
} // namespace reasampler
+62
View File
@@ -0,0 +1,62 @@
#pragma once
// guid_diff — the pure, REAPER-free core of the D2 Wave-2 new-content detection.
//
// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO
// vendor/ includes. Standard library only. Unit-tested outside the DAW.
//
// The shell (bank_panel timer) reads REAPER's live track/item GUID set each tick;
// this module owns the DECISION of "which GUIDs are new since the last tick" and the
// first-poll-after-open guard so pre-existing content is never mass-tagged. Keeping
// this here — rather than in the shell — means the fiddly baseline/diff logic is
// unit-tested, mirroring how view_tree splits the folder-depth walk out of view.cpp.
//
// The shell then hands the "new since last tick" GUIDs to the pure autoTagNewContent
// (view_mode_model) to produce the membership writes.
#include <set>
#include <string>
#include <vector>
namespace reasampler {
// The GUIDs present in `current` but absent from `previous` — i.e. new since the
// previous poll. Order is the set's ascending order (deterministic; the caller does
// not depend on discovery order). Empty GUIDs are ignored (a GUID read failure at the
// shell boundary must never be tagged).
std::vector<std::string> newGuids(const std::set<std::string>& previous,
const std::set<std::string>& current);
// Tracks the live GUID set across polls for ONE project, implementing the
// first-poll-after-open guard: the first observation after a (re)start establishes a
// BASELINE and reports NOTHING new, so pre-existing content stays at its default
// (Arrange) rather than being mass-tagged. Every subsequent observe() returns only the
// GUIDs created since the prior observe().
//
// Project switches are handled by reset(): the shell detects a project change (the
// active ReaProject* / project GUID changed) and calls reset() so the next observe()
// re-baselines against the newly-opened project instead of diffing across two
// unrelated projects (which would spuriously "detect" the entire new project as new
// content, or miss content because a same-GUID collision looked pre-existing).
class GuidBaseline {
public:
// Observes the current live GUID set. On the FIRST call after construction or
// reset() this records the baseline and returns {} (nothing is "new" at open).
// On every later call it returns the GUIDs added since the previous call and
// advances the baseline to `current`. Empty GUIDs are ignored.
std::vector<std::string> observe(const std::set<std::string>& current);
// Re-arms the first-poll guard: the next observe() re-baselines and reports
// nothing new. Called on a project switch so detection never diffs across
// projects.
void reset();
// True until the first observe() after construction/reset — exposed for the shell
// to reason about (and for tests) about whether a baseline is established yet.
bool primed() const { return primed_; }
private:
std::set<std::string> baseline_;
bool primed_ = false; // false ⇒ next observe() sets the baseline
};
} // namespace reasampler
+33
View File
@@ -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 <cstdio>
#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<int>(GetMediaItemInfo_Value(it, "I_FIXEDLANE"));
char parm[32];
std::snprintf(parm, sizeof(parm), "P_LANENAME:%d", laneIdx);
char buf[512] = {0};
if (!GetSetMediaTrackInfo_String(tr, parm, buf, false)) return {};
return std::string(buf);
}
} // namespace reasampler
+34
View File
@@ -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 <string>
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
+51
View File
@@ -0,0 +1,51 @@
// lane_keys implementation — pure string convention, no REAPER. See lane_keys.h.
#include "lane_keys.h"
#include <cstring>
namespace reasampler {
namespace {
// Does `s` start with the managed-lane prefix?
bool hasManagedPrefix(const std::string& s) {
const std::size_t n = std::strlen(kManagedLanePrefix);
return s.size() >= n && s.compare(0, n, kManagedLanePrefix) == 0;
}
} // namespace
bool isManagedLaneName(const std::string& laneName) {
return hasManagedPrefix(laneName);
}
std::optional<std::string> managedLaneKey(const std::string& laneName) {
if (!hasManagedPrefix(laneName)) return std::nullopt; // manual/unnamed ⇒ no key
// The durable name IS the key (stable across ordinal renumber). Keeping the full
// prefixed name — rather than stripping to the mode id — means the key is globally
// unambiguous and the ownership index's mode field remains the single source of
// truth for which mode owns the lane.
return laneName;
}
std::string laneNameForMode(const std::string& modeId) {
return std::string(kManagedLanePrefix) + modeId;
}
std::optional<std::string> 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.
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
+85
View File
@@ -0,0 +1,85 @@
#pragma once
// lane_keys — the pure, REAPER-free convention that maps a REAPER fixed lane's
// durable NAME (P_LANENAME:n) to the opaque lane-key the pure view_mode_model uses,
// and the managed/manual heuristic that rides on it.
//
// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL. std only.
// Unit-tested outside the DAW. The shell (view.cpp) reads each lane's P_LANENAME:n
// string from REAPER and asks this module whether the lane is tool-managed and what
// its stable lane-key is; the shell never re-derives the prefix rule itself.
//
// -- Design point #2 (lane-identity robustness) resolution --------------------
//
// REAPER exposes no durable per-lane GUID. The only lane identity is the ordinal
// I_FIXEDLANE, which REAPER RENUMBERS when lanes are reordered or deleted — so keying
// the ownership index by raw ordinal would silently corrupt managed/manual ownership
// on any reorder. REAPER DOES expose a writable, durable lane NAME (P_LANENAME:n) that
// travels with the lane across renumber. So the tool names each lane it mints with a
// stable, prefixed identity ("reasampler:<mode>") and keys the ownership index by that
// NAME, not the ordinal. On each apply the shell walks the track's lanes by current
// ordinal, reads each name, and reconciles ordinal<->laneKey — so a C_LANEPLAYS:N
// write always targets the lane's CURRENT ordinal for a given durable key even after a
// reorder. A lane WITHOUT the prefix was not minted by the tool: it is manual and
// off-limits (the fixed-lane analog of "never touch mute/solo").
//
// -- Design point #1 (manual-lane exemption) resolution -----------------------
//
// The SAME prefix rule is the manual/managed heuristic for auto-tag: an item on a lane
// whose name lacks the "reasampler:" prefix is on a manual lane and is EXEMPT from
// auto-tag. isManagedLaneName is the single predicate both the toggle-apply path and
// the new-content detection path consult, so the boundary is defined in one place and
// unit-tested.
#include <optional>
#include <string>
namespace reasampler {
// The prefix the tool stamps on every lane NAME it mints. A lane name carrying this
// prefix is a managed lane the tool created; any other name (or an empty/unnamed lane)
// is a user-minted manual lane. Stable-forever: changing it would strand the ownership
// of every lane in every already-saved project, so treat it like an action id string.
inline constexpr const char* kManagedLanePrefix = "reasampler:";
// True iff `laneName` is a tool-minted managed-lane name (carries kManagedLanePrefix).
// This is the load-bearing managed/manual predicate for BOTH design points #1 and #2.
bool isManagedLaneName(const std::string& laneName);
// The opaque lane-key the pure model keys by, for a lane with REAPER name `laneName`.
// For a managed lane the key IS the durable name (stable across ordinal renumber). For
// a manual/unnamed lane there is no managed key: returns std::nullopt so the caller
// treats the lane as manual (never driven, items on it exempt from auto-tag).
std::optional<std::string> managedLaneKey(const std::string& laneName);
// The lane NAME the tool mints for the lane owned by `modeId` (kManagedLanePrefix +
// modeId). The inverse of managedLaneKey for a managed lane: managedLaneKey(
// laneNameForMode(m)) == kManagedLanePrefix + m. Exposed for the Wave-3 lane-minting
// path and for tests; the apply path in this wave only READS names, but the round-trip
// contract is asserted here so minting and reading cannot drift.
std::string laneNameForMode(const std::string& modeId);
// 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<std::string> 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
// (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
+33 -5
View File
@@ -218,8 +218,22 @@ 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();
// 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);
}
// Reflect a live bank change (capture / project load) in the docked grid.
// Cheap when the bank is unchanged (a fingerprint compare); repaints only on
@@ -600,13 +614,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;
@@ -627,8 +648,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
+28
View File
@@ -2,6 +2,7 @@
#include <algorithm>
#include <climits>
#include <cmath>
// peaks implementation.
//
@@ -63,4 +64,31 @@ Envelope computeEnvelope(const std::vector<AudioSample>& interleaved,
return envelope;
}
std::size_t lastFrameAboveThreshold(const std::vector<AudioSample>& 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
+38
View File
@@ -66,4 +66,42 @@ Envelope computeEnvelope(const std::vector<AudioSample>& 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<std::size_t>(-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<AudioSample>& interleaved,
std::size_t channelCount,
std::size_t frameCount,
AudioSample linearThreshold);
} // namespace reasampler
+29
View File
@@ -198,6 +198,13 @@ void ReaSamplerSession::saveToActiveProject() {
SetProjExtState(static_cast<ReaProject*>(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<ReaProject*>(proj), kProjExtNamespace,
kProjExtTailKey, tailJson.c_str());
MarkProjectDirty(static_cast<ReaProject*>(proj));
}
@@ -222,6 +229,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<TailSetting> 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) {
@@ -239,6 +263,11 @@ void ReaSamplerSession::loadFromProject(void* proj, const std::string& projectDi
// only — no visibility/processing is applied here (that is D4).
view_ = loadViewModel(static_cast<ReaProject*>(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<ReaProject*>(proj));
if (!proj) {
book_ = BankBook{};
return;
+26 -4
View File
@@ -21,6 +21,7 @@
#include "bank_book.h"
#include "bank_model.h"
#include "tail_control.h"
#include "view_mode_model.h"
namespace reasampler {
@@ -50,6 +51,13 @@ inline constexpr const char* kProjExtBanksKey = "banks";
// 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
@@ -105,10 +113,19 @@ public:
ViewModeModel& view() { return view_; }
const ViewModeModel& view() const { return view_; }
// Serialize the current book (under the `banks` key) and view model to the active
// project's ext state (namespace "reasampler"), and clear the retired legacy
// `bank_index` key. Non-destructive beyond writing our own ext-state keys. Safe
// to call when there is no active/saved project (it no-ops).
// 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 book (under the `banks` key), view model, and tail setting
// to the active project's ext state (namespace "reasampler"), and clear the retired
// legacy `bank_index` key. Non-destructive beyond writing our own ext-state keys.
// Safe to call when there is no active/saved project (it no-ops).
void saveToActiveProject();
// Poll the active project. Detects a project load (active project changed)
@@ -136,6 +153,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
+18
View File
@@ -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
+14
View File
@@ -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 {
+102 -3
View File
@@ -3,6 +3,10 @@
#include "tail_control.h"
#include <algorithm>
#include <cerrno>
#include <cstdio>
#include <cstdlib>
#include <cstring>
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<TailMode> 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<TailSetting> 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<TailMode> mode = modeFromInt(static_cast<int>(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
+26 -5
View File
@@ -9,6 +9,7 @@
// only (plus render_settings for the pure TailMode enum). Builds and unit-tests
// without REAPER.
#include <optional>
#include <string>
#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<TailSetting> deserializeTailSetting(const std::string& json);
} // namespace reasampler
+442
View File
@@ -10,10 +10,16 @@
#include "view.h"
#include <cstdio>
#include <map>
#include <optional>
#include <set>
#include <string>
#include <utility>
#include <vector>
#include "item_read.h"
#include "lane_keys.h"
#include "track_guid.h"
#include "view_tree.h"
@@ -22,6 +28,7 @@
#define REAPERAPI_WANT_GetTrack
#define REAPERAPI_WANT_GetMediaTrackInfo_Value
#define REAPERAPI_WANT_SetMediaTrackInfo_Value
#define REAPERAPI_WANT_GetSetMediaTrackInfo_String
#define REAPERAPI_WANT_TrackFX_GetCount
#define REAPERAPI_WANT_TrackFX_GetOffline
#define REAPERAPI_WANT_TrackFX_SetOffline
@@ -29,12 +36,56 @@
#define REAPERAPI_WANT_Undo_EndBlock2
#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
#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;
// 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<double>(kLanesDisplayAsNormal));
const int settings = static_cast<int>(GetMediaTrackInfo_Value(tr, "C_LANESETTINGS"));
SetMediaTrackInfo_Value(tr, "C_LANESETTINGS",
static_cast<double>(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) {
@@ -132,6 +183,278 @@ void restoreFxOffline(MediaTrack* tr, const std::vector<FxOfflineOp>& fxOffline)
}
}
// -- Managed-lane application (D2 Wave 2) ------------------------------------
//
// The pure planner emits LanePlayOps keyed by (trackGuid, laneKey) where laneKey is
// the lane's DURABLE name (lane_keys convention: "reasampler:<mode>"). REAPER's
// C_LANEPLAYS:N is keyed by the lane's CURRENT ORDINAL, which renumbers on reorder.
// So before applying, we build the ordinal<->key reconcile for a track by reading each
// lane's P_LANENAME:n; the write then targets the correct current ordinal for a given
// durable key even after a reorder (design point #2). A lane whose name lacks the
// managed prefix is manual and never appears in this map, so it can never be driven.
// Reads lane index `laneIdx`'s durable name off track `tr` (P_LANENAME:n). Empty if
// the lane is unnamed or the param is unavailable (non-fixed-lane track).
std::string laneName(MediaTrack* tr, int laneIdx) {
char parm[32];
std::snprintf(parm, sizeof(parm), "P_LANENAME:%d", laneIdx);
char buf[512] = {0};
if (!GetSetMediaTrackInfo_String(tr, parm, buf, false)) return {};
return std::string(buf);
}
// Maps each MANAGED lane's durable key -> its current ordinal on `tr`, by walking the
// track's I_NUMFIXEDLANES lanes and reading each name. Manual (unprefixed/unnamed)
// lanes are omitted, so a key absent from the map is a lane the tool must not drive.
std::map<std::string, int> managedLaneOrdinals(MediaTrack* tr) {
std::map<std::string, int> byKey;
const int numLanes = static_cast<int>(GetMediaTrackInfo_Value(tr, "I_NUMFIXEDLANES"));
for (int lane = 0; lane < numLanes; ++lane) {
std::optional<std::string> key = managedLaneKey(laneName(tr, lane));
if (key) byKey.emplace(*key, lane); // first ordinal wins if names collide
}
return byKey;
}
// Drives one managed lane on `tr` to `lanePlays` (C_LANEPLAYS value) via the
// TRACK-SIDE C_LANEPLAYS:N write. Track-side C_LANEPLAYS:N alone produces the
// hide+silence effect for all items on lane N — no per-item write is needed or
// possible (item-side C_LANEPLAYS is marked read-only in the SDK).
// B_FIXEDLANE_HIDDEN is READ-ONLY (SDK) — hide/show follows from C_LANEPLAYS=0/1,
// never written directly. Non-destructive: only reversible play/show flags; no item
// is moved or deleted.
//
// DAW-VERIFY: confirm that track-side C_LANEPLAYS:N alone hides+silences all items
// on lane N without a per-item write. (SDK marks item-side C_LANEPLAYS as read-only;
// the track-side write is the documented mechanism.)
void applyLanePlays(MediaTrack* tr, int laneIdx, int lanePlays) {
char parm[32];
std::snprintf(parm, sizeof(parm), "C_LANEPLAYS:%d", laneIdx);
SetMediaTrackInfo_Value(tr, parm, static_cast<double>(lanePlays));
}
// Applies the plan's managed-lane ops. Groups ops by track, resolves each op's durable
// laneKey to the track's current ordinal (skipping any key not present on the live
// track — a stale/renamed/deleted managed lane is pruned, never mis-driven), enables
// fixed-lane mode on any track that carries a managed lane, and drives C_LANEPLAYS.
// UpdateTimeline() is called ONCE at the end (SDK: required after I_FREEMODE changes).
// Returns true if any track's I_FREEMODE was (re)set to fixed lanes (⇒ needs timeline
// refresh). MANAGED lanes only — plan.lanes never contains a manual lane (pure planner
// gates on the ownership index), and a manual lane's name never resolves to a key here,
// so the invariant is enforced twice.
bool applyLaneOps(const std::vector<std::pair<std::string, MediaTrack*>>& handleByGuid,
const std::vector<LanePlayOp>& lanes) {
if (lanes.empty()) return false;
// Group op indices by track guid so we read each track's lane map once.
std::map<std::string, std::vector<const LanePlayOp*>> byTrack;
for (const LanePlayOp& op : lanes) byTrack[op.trackGuid].push_back(&op);
bool touchedFreeMode = false;
for (const auto& [guid, ops] : byTrack) {
MediaTrack* tr = resolve(handleByGuid, guid);
if (!tr) continue; // stale GUID — prune
// Ensure fixed-lane mode is on before driving lane play state. A track carrying
// a managed lane must be in I_FREEMODE=2; set it only if not already, and flag
// that a timeline refresh is owed. 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<int>(GetMediaTrackInfo_Value(tr, "I_FREEMODE"));
if (freeMode != kFreeModeFixedLanes) {
SetMediaTrackInfo_Value(tr, "I_FREEMODE",
static_cast<double>(kFreeModeFixedLanes));
applyTransparentLaneDisplay(tr); // tool-managed track ⇒ read like a normal track
touchedFreeMode = true;
}
// Reconcile durable keys -> current ordinals on THIS track, then drive each op.
const std::map<std::string, int> ordinals = managedLaneOrdinals(tr);
for (const LanePlayOp* op : ops) {
auto it = ordinals.find(op->laneKey);
if (it == ordinals.end()) continue; // key not live on this track — prune
applyLanePlays(tr, it->second, op->lanePlays);
}
}
return touchedFreeMode;
}
// -- 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.
// 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
// track per item (avoids the quadratic that a per-item find would incur).
std::map<std::string, MediaItem*> itemHandlesByGuid(MediaTrack* tr) {
std::map<std::string, MediaItem*> byGuid;
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()) 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<std::string> 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<LaneTrack> readLaneTracks(
const ViewModeModel& model,
const std::vector<std::pair<std::string, MediaTrack*>>& handleByGuid) {
std::vector<LaneTrack> tracks;
tracks.reserve(handleByGuid.size());
for (const auto& [guid, tr] : handleByGuid) {
LaneTrack lt;
lt.trackGuid = guid;
const bool fixedLane =
static_cast<int>(GetMediaTrackInfo_Value(tr, "I_FREEMODE")) == kFreeModeFixedLanes;
const int itemCount = CountTrackMediaItems(tr);
lt.items.reserve(static_cast<std::size_t>(itemCount));
for (int i = 0; i < itemCount; ++i) {
MediaItem* it = GetTrackMediaItem(tr, i);
if (!it) continue;
const std::string ig = itemGuid(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 ? itemLaneName(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<int>(GetMediaItemInfo_Value(it, "I_FIXEDLANE"));
if (current == laneOrdinal) return false; // already there — no-op
SetMediaItemInfo_Value(it, "I_FIXEDLANE", static_cast<double>(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<std::pair<std::string, MediaTrack*>>& handleByGuid) {
bool changed = false;
// Group mints + assigns by track so each track is set up once.
std::map<std::string, std::vector<const LaneMint*>> mintsByTrack;
for (const LaneMint& m : plan.mints) mintsByTrack[m.trackGuid].push_back(&m);
std::map<std::string, std::vector<const LaneAssign*>> 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). 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<int>(GetMediaTrackInfo_Value(tr, "I_FREEMODE"));
if (freeMode != kFreeModeFixedLanes) {
SetMediaTrackInfo_Value(tr, "I_FREEMODE", static_cast<double>(kFreeModeFixedLanes));
applyTransparentLaneDisplay(tr); // tool-split track ⇒ read like a normal track
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.
// 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<int>(GetMediaTrackInfo_Value(tr, "I_NUMFIXEDLANES"));
// Which managed keys are already present on this track (durable-name reconcile).
std::map<std::string, int> 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.
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
// Append at the current tail ordinal, grow the tracked count, stamp its name.
const int laneIdx = laneCount++;
SetMediaTrackInfo_Value(tr, "I_NUMFIXEDLANES", static_cast<double>(laneCount));
char parm[32];
std::snprintf(parm, sizeof(parm), "P_LANENAME:%d", laneIdx);
std::vector<char> 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<std::string, int> ordinals = managedLaneOrdinals(tr);
const std::map<std::string, MediaItem*> 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) {
@@ -194,6 +517,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,8 +561,117 @@ 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;
}
bool mintManagedLanes(ViewModeModel& model, ReaProject* proj) {
std::vector<std::pair<std::string, MediaTrack*>> handleByGuid;
std::vector<TrackFolderEntry> 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 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<LaneTrack> tracks = readLaneTracks(model, handleByGuid);
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
// 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 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;
}
// 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<std::pair<std::string, MediaTrack*>> 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<int>(GetMediaTrackInfo_Value(tr, "I_FREEMODE"));
if (freeMode != kFreeModeFixedLanes) continue; // no fixed lanes ⇒ nothing managed
const int numLanes = static_cast<int>(GetMediaTrackInfo_Value(tr, "I_NUMFIXEDLANES"));
for (int lane = 0; lane < numLanes; ++lane) {
const std::string name = laneName(tr, lane);
std::optional<std::string> key = managedLaneKey(name);
if (!key) continue; // manual/unnamed lane — leave off the index
std::optional<std::string> 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);
}
}
}
} // namespace reasampler
+40
View File
@@ -52,4 +52,44 @@ 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 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). 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
// 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
+142 -4
View File
@@ -1,10 +1,15 @@
#include "view_mode_model.h"
#include <algorithm>
#include <cassert>
#include <cerrno>
#include <climits>
#include <cstdio>
#include <cstdlib>
#include <set>
#include <utility>
#include "lane_keys.h" // laneNameForMode — the ONE durable managed-lane-key convention
// view_mode_model implementation.
//
@@ -121,6 +126,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;
}
@@ -146,6 +158,116 @@ std::vector<AutoTag> autoTagNewContent(const std::vector<std::string>& newTrackG
return tags;
}
std::vector<ItemRetagOp> planItemRetag(const std::vector<RetagItem>& selected,
const std::string& targetMode) {
std::vector<ItemRetagOp> 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
// ---------------------------------------------------------------------------
LaneMintPlan planLaneMinting(const ViewModeModel& model, const FolderTree& tree,
const std::vector<LaneTrack>& 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<std::string, std::set<std::string>> visibleModesOf;
for (const Mode& mode : model.modes().all()) {
const std::set<std::string> 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;
// 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<std::string> 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
ownItemModes.insert(item.modeId);
}
// 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;
// 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;
// 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<std::string>& laneModes = ownItemModes;
// 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<int>(laneModes.size())});
for (const std::string& mode : laneModes) {
plan.mints.push_back(
LaneMint{track.trackGuid, laneNameForMode(mode), mode});
}
// 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
plan.assigns.push_back(LaneAssign{
item.guid, track.trackGuid, laneNameForMode(item.modeId)});
}
}
return plan;
}
// ---------------------------------------------------------------------------
// planner helpers
// ---------------------------------------------------------------------------
@@ -319,8 +441,20 @@ TogglePlan ViewModeModel::planToggle(const FolderTree& tree, const std::string&
// "never touch mute/solo"). Lane ownership is not a tree property, so this walks the
// ownership index directly, not the FolderTree; a project with no fixed lanes leaves
// plan.lanes empty and the plan is byte-identical to a D1 plan.
#ifndef NDEBUG
// Debug-time guard for the one-managed-lane-per-mode-per-track exclusivity
// assumption (see laneModeState). Two managed lanes on the same track claiming the
// same mode would both be told to play exclusively on that mode's toggle, which
// REAPER cannot honor. Cheap set membership over the (usually tiny) managed-lane
// set; compiled out of release builds.
std::set<std::pair<std::string, std::string>> seenTrackMode; // (trackGuid, mode)
#endif
for (const auto& [ref, ownership] : lanes_.all()) {
if (!ownership.isManaged()) continue; // manual lanes are off-limits
#ifndef NDEBUG
assert(seenTrackMode.insert({ref.trackGuid, *ownership.managedMode}).second &&
"two managed lanes on one track claim the same mode (exclusivity broken)");
#endif
const int lanePlays = laneModeState(*ownership.managedMode, targetMode);
plan.lanes.push_back(LanePlayOp{ref.trackGuid, ref.laneKey, lanePlays});
}
@@ -448,7 +582,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 +591,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 +608,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 +631,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);
+190
View File
@@ -519,6 +519,196 @@ std::vector<AutoTag> autoTagNewContent(const std::vector<std::string>& newTrackG
const std::vector<NewItem>& 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<ItemRetagOp> planItemRetag(const std::vector<RetagItem>& 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
// 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, 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
// 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<LaneItem> 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<TrackSplit> splits;
std::vector<LaneMint> mints; // managed lanes to mint (name + ownership write)
std::vector<LaneAssign> assigns; // item→managed-lane assignments
bool empty() const {
return splits.empty() && mints.empty() && assigns.empty();
}
};
// 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).
// * 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
// 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
// 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 ViewModeModel& model, const FolderTree& tree,
const std::vector<LaneTrack>& 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).
+160
View File
@@ -0,0 +1,160 @@
// wav_trim — pure implementation. See wav_trim.h. NO REAPER / SWELL / vendor.
#include "wav_trim.h"
#include <cstring> // 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<std::uint8_t>& b, std::size_t off) {
return static_cast<std::uint16_t>(b[off] | (b[off + 1] << 8));
}
std::uint32_t readU32LE(const std::vector<std::uint8_t>& b, std::size_t off) {
return static_cast<std::uint32_t>(b[off]) |
(static_cast<std::uint32_t>(b[off + 1]) << 8) |
(static_cast<std::uint32_t>(b[off + 2]) << 16) |
(static_cast<std::uint32_t>(b[off + 3]) << 24);
}
bool tagEquals(const std::vector<std::uint8_t>& 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<std::uint8_t>& 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<AudioSample> extractFloatFrames(const std::vector<std::uint8_t>& bytes,
const WavLayout& layout,
std::size_t startFrame,
std::size_t frameCount) {
std::vector<AudioSample> out;
if (!layout.valid) return out;
const std::size_t bytesPerFrame =
static_cast<std::size_t>(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<std::size_t>(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<std::uint32_t>(keptDataBytes);
plan.riffSizeFieldOffset = layout.riffSizeFieldOffset;
// RIFF size counts everything after the 8-byte "RIFF"+size prefix.
plan.newRiffSize = static_cast<std::uint32_t>(plan.newFileByteLength - 8);
return plan;
}
} // namespace reasampler
+101
View File
@@ -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 <cstddef>
#include <cstdint>
#include <vector>
#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<std::size_t>(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<std::uint8_t>& 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<AudioSample> extractFloatFrames(const std::vector<std::uint8_t>& 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