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:
+116
-2
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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_))
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
@@ -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
|
||||
Reference in New Issue
Block a user