diff --git a/src/app/main.cpp b/src/app/main.cpp index a721162..65469e9 100644 --- a/src/app/main.cpp +++ b/src/app/main.cpp @@ -1,25 +1,16 @@ // main.cpp — the SINGLE translation unit that OWNS the REAPER API pointers. // -// This file is the entire contract between REAPER and the extension: -// * At startup REAPER scans UserPlugins/ for reaper_*.dll|dylib|so and -// dlopen()s each one, then looks up ONE exported symbol: ReaperPluginEntry -// (that name is produced by the REAPER_PLUGIN_ENTRYPOINT macro). -// * REAPER calls it, handing over `rec` — a small dispatch struct. -// - rec->GetFunc(name) resolves any REAPER API function to a pointer -// - rec->Register(what,ptr) plugs OUR callbacks into REAPER -// * REAPERAPI_LoadAPI(rec->GetFunc) walks reaper_plugin_functions.h and -// fills in every global function pointer (ShowConsoleMsg, InsertMedia...). +// REAPER dlopen()s reaper_*.dll|dylib|so from UserPlugins/ and calls the exported +// ReaperPluginEntry, handing over `rec` (rec->GetFunc resolves API pointers, +// rec->Register plugs our callbacks in). Exactly ONE .cpp defines +// REAPERAPI_IMPLEMENT (this one) — that allocates storage for the global API +// pointers every other TU gets `extern`. Never let a second TU define it. // -// Exactly ONE .cpp defines REAPERAPI_IMPLEMENT (this one) — that allocates -// storage for those global pointers. Every other .cpp includes -// reaper_plugin_functions.h WITHOUT the define and gets `extern` declarations. -// -// Since Q-W3 this TU is ONLY pointers + entry + dispatch; since Q-W6 its own -// action family registers through the DATA-DRIVEN TABLE below (kMainActionRows + -// action_registry's registerActionTable/actionTableHandleCommand/ -// unregisterActionTable) — adding a bindable action here means adding ONE row and -// its handler function, nothing else (OCP). The design_view / bank / ingest -// families keep their own register/handle/unregister triples, called from entry. +// This TU is ONLY pointers + entry + dispatch. Its own action family registers +// through the data-driven table below (buildMainActionTable + action_registry) — +// adding a bindable action means adding ONE row and its handler function (OCP). The +// design_view / bank / ingest families keep their own register/handle/unregister +// triples, called from entry. #define REAPERAPI_IMPLEMENT #include "reaper_plugin.h" @@ -32,9 +23,9 @@ #include "core/capture/render_settings.h" // captureActionTable #include "core/version/app_version.h" // appVersion #include "ingest.h" -#include "shell/actions/action_registry.h" // the Q-W6 registration table -#include "shell/actions/bank_actions.h" // multi-bank action family (B3; Q-W4 home) -#include "shell/actions/design_view_actions.h" // Design View action family (D4; Q-W4 home) +#include "shell/actions/action_registry.h" // the registration table +#include "shell/actions/bank_actions.h" // multi-bank action family +#include "shell/actions/design_view_actions.h" // Design View action family #include "shell/capture/capture_batch.h" // batch + recapture action bodies #include "shell/capture/capture_orchestrator.h" // single-capture / realtime / insert action bodies #include "shell/capture/realtime_lifecycle.h" // in-flight realtime state + tick driver @@ -46,20 +37,13 @@ namespace capture = reasampler::capture; // Globals other files reference via `extern`. -REAPER_PLUGIN_HINSTANCE g_hInst = nullptr; // this module's instance handle -reaper_plugin_info_t* g_rec = nullptr; // REAPER's dispatch struct +REAPER_PLUGIN_HINSTANCE g_hInst = nullptr; +reaper_plugin_info_t* g_rec = nullptr; -// Retired command-id SUFFIXES. Kept ONLY to mirror-unregister them on unload so a -// user's stale keybindings are cleaned up. Never re-register these. Composed through -// the channel prefix at unload (channelIdFor) so a beta unload clears beta-qualified -// retired ids and a stable unload clears stable's — each channel cleans up only its -// own family. -// * The M7 four-mode ids (tracks/items/razor WET). -// * CAPTURE_MASTER and CAPTURE_MASTER_REALTIME — the master offline scope and the -// master realtime action are REMOVED (capture is now item + track only; realtime -// taps the selected track). Their shipped ids are retired so old keybindings clear. -// * CAPTURE_ITEM_TAIL and CAPTURE_TRACK_TAIL — the former per-action tail variants -// are REMOVED; tail is now a panel-setting toggle, not a paired action. +// Retired command-id SUFFIXES: kept ONLY to mirror-unregister on unload so a user's +// stale keybindings are cleaned up. Never re-register these. The four-mode WET ids, +// the removed master scope/realtime actions, and the removed per-action tail variants +// (tail is now a panel toggle, not a paired action). static const char* const kRetiredCaptureCmdSuffixes[] = { "CAPTURE_TRACKS_WET", "CAPTURE_ITEMS_WET", @@ -70,35 +54,30 @@ static const char* const kRetiredCaptureCmdSuffixes[] = { "CAPTURE_TRACK_TAIL", }; -// The persistence session (M4): owns the in-memory BankModel and bridges it to -// project ext state. A timer tick drives g_session.poll() to detect project -// load / Save-As; capture adds Samples to g_session.bank() — which (B2) resolves to -// the ACTIVE bank's index inside the session's BankBook; after a capture we serialize -// the book back into the active project's ext state (the `banks` key) so it travels -// with the .rpp. Replaces the M3 session-only g_bank. +// Owns the in-memory BankModel and bridges it to project ext state. A timer tick +// drives g_session.poll() to detect project load / Save-As; capture adds Samples to +// g_session.bank() (resolves to the active bank's index), and we serialize the book +// back into the active project's ext state (the `banks` key) so it travels with the .rpp. static reasampler::ReaSamplerSession g_session; // Command id of the TOGGLE_BANK_PANEL row, resolved from the table once at load so // OnToggleAction's checked-state poll is a single int compare (no per-poll lookup). static int g_cmdToggleBankPanel = 0; -// --- Action handlers (the table's function pointers) -------------------------- -// -// Each is a thin stateless routing shim: (session, per-row arg) -> the action body -// hoisted in Q-W3/Q-W4 (shell/capture/, shell/panel/). The bodies own all behavior; -// these exist only so the table rows can be plain data with flat function pointers. +// Each handler is a thin stateless routing shim: (session, per-row arg) -> the +// action body in shell/capture/ or shell/panel/, existing only so table rows can be +// plain data with flat function pointers. -// Capture scope family: `arg` is the captureActionTable() row index — the table rows -// below are built by iterating that pure taxonomy, so the routing stays 1:1 by -// construction (never a hand-kept parallel list). +// `arg` is the captureActionTable() row index — the table rows below are built by +// iterating that pure taxonomy, so the routing stays 1:1 by construction. static void RunCaptureScopeRow(int arg) { capture::RunCapture(g_session, capture::captureActionTable()[static_cast(arg)]); } static void RunToggleBankPanel(int) { reasampler::bankPanelToggle(); } static void RunCaptureItemAssign(int) { capture::RunCaptureItemAssign(g_session); } -// Insert: `arg` != 0 is the EXPLICIT conform-to-project-tempo opt-in (CONTEXT.md -// §insert: conform is opt-in, never silent); 0 inserts at native length. +// `arg` != 0 is the EXPLICIT conform-to-project-tempo opt-in (never silent); 0 +// inserts at native length. static void RunInsertSelected(int arg) { capture::RunInsertSelected(g_session, arg != 0); } @@ -108,24 +87,15 @@ static void RunCaptureRealtime(int) { capture::RunCaptureRealtimeTrack(g_sessi static void RunCancelRealtime(int) { capture::RunCancelRealtime(g_session); } static void RunRecaptureFromSource(int) { capture::RunRecaptureFromSource(g_session); } static void RunShowVersion(int) { - // On-demand version readout — the ONLY version output on any path (Phase V: no - // unconditional startup print; routine console chatter pops the console window). + // On-demand only — no unconditional startup print (routine console chatter pops + // the console window). ShowConsoleMsg(("ReaSampler " + reasampler::version::appVersion() + "\n").c_str()); } -// --- The registration table (Q-W6) -------------------------------------------- -// -// ONE row per bindable action this TU owns: FOREVER-STABLE id suffix (channel prefix -// composed at register — stable rebuilds the exact shipped id, e.g. -// "CEREBELLUM_REASAMPLER_CAPTURE_TRACK"; beta its isolated forever-family), the -// Actions-list phrase (after the "ReaSampler[ beta]: " lead), the handler, and its -// per-row arg. Registration, hookcommand dispatch, and the unload mirror-unregister -// all iterate this data — adding an action = adding a row + a handler above. -// -// The capture scope rows (CAPTURE_ITEM / CAPTURE_TRACK) come first, sourced from the -// pure captureActionTable() taxonomy (render_settings) — suffix/phrase live in that -// one testable list, and `arg` carries the row index back to RunCapture. The -// remaining rows are this TU's singles, in the pre-table registration order. +// ONE row per bindable action this TU owns: FOREVER-STABLE id suffix, Actions-list +// phrase, handler, per-row arg. Registration, hookcommand dispatch, and the unload +// mirror-unregister all iterate this data. The capture scope rows come first, +// sourced from the pure captureActionTable() taxonomy; the rest are this TU's singles. static std::vector buildMainActionTable() { using reasampler::ActionTableRow; std::vector rows; @@ -135,40 +105,32 @@ static std::vector buildMainActionTable() { rows.push_back(ActionTableRow{cap[i].commandSuffix, cap[i].descriptionPhrase, &RunCaptureScopeRow, static_cast(i)}); - // M5: show/hide the docked bank panel (display-only; never captures/inserts). + // Show/hide the docked bank panel (display-only; never captures/inserts). rows.push_back({"TOGGLE_BANK_PANEL", "toggle bank panel", &RunToggleBankPanel}); - // S8: Item-scope capture + assignment-request write (capture family because it - // leans on the capture render machinery; the other ingest surfaces live in the - // ingest family and the panel drop callback). rows.push_back({"CAPTURE_ITEM_ASSIGN", "capture selected item into bank + assign to active instance", &RunCaptureItemAssign}); - // M6: place the panel's selected sample at the edit cursor. Two variants that - // differ ONLY in InsertOptions — native length vs the explicit conform opt-in. + // Two variants differing ONLY in InsertOptions — native length vs conform opt-in. rows.push_back({"INSERT_SELECTED", "insert selected sample at edit cursor", &RunInsertSelected, 0}); rows.push_back({"INSERT_SELECTED_CONFORM", "insert selected sample at edit cursor (conform to tempo)", &RunInsertSelected, 1}); - // M11: one action fires N captures (per selected item / per razor area); the - // original selection is restored on every exit path. Bank-only, never places. + // One action fires N captures (per selected item / per razor area); the original + // selection is restored on every exit path. Bank-only, never places. rows.push_back({"CAPTURE_BATCH_ITEMS", "batch capture selected items (one per item)", &RunBatchCaptureItems}); rows.push_back({"CAPTURE_BATCH_RAZOR", "batch capture razor areas (one per area)", &RunBatchCaptureRazor}); - // M8: realtime sibling of the offline CAPTURE_TRACK scope — records the selected - // track's own output into a hidden temp track, dialog-free — plus its - // cancel-in-flight companion (stop + restore, non-destructive). + // Realtime sibling of the offline CAPTURE_TRACK scope, plus its cancel-in-flight + // companion (stop + restore, non-destructive). rows.push_back({"CAPTURE_TRACK_REALTIME", "capture selected track (realtime)", &RunCaptureRealtime}); rows.push_back({"CANCEL_REALTIME_CAPTURE", "cancel realtime capture", &RunCancelRealtime}); - // M10: regenerate the selected PROVENANCED sample from its recorded source's - // current state, in place. Bank-only, never places on the timeline. rows.push_back({"RECAPTURE_FROM_SOURCE", "re-capture from source", &RunRecaptureFromSource}); - // Phase V: on-demand version readout for bug reports. rows.push_back({"SHOW_VERSION", "show version", &RunShowVersion}); return rows; @@ -180,74 +142,52 @@ static std::vector buildMainActionTable() { static void OnTimer() { // Advance any in-flight realtime capture FIRST, so a project switch is caught and - // the capture torn down/restored before session.poll() reacts to that switch. - // LOAD-BEARING (CONTEXT.md §Phase Q): the idle fast-path is a SINGLE POINTER - // TEST — the cross-TU drive call is made only when a capture is in flight. + // torn down/restored before session.poll() reacts to that switch. LOAD-BEARING: + // the idle fast-path is a SINGLE POINTER TEST — drive only when a capture is live. if (capture::g_rtCapture) capture::DriveRealtimeCapture(g_session); g_session.poll(); - // D4 reapply-on-open glue. persist stays MODEL-ONLY (it loads the saved view - // model but deliberately does NOT apply visibility — that would couple persist - // to the view shell). Instead poll() raises a one-shot load signal; here — the - // integration layer that already drives both persist and the view shell — we - // drain it and reapply the SAVED active mode's visibility/processing so opening a - // 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). - // - // 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. + // persist stays MODEL-ONLY (loads the saved view model but does not apply + // visibility, to avoid coupling persist to the view shell); poll() raises a + // one-shot load signal that we drain here to reapply the SAVED active mode so a + // project saved in Design mode parks Arrange tracks automatically. The same + // signal re-arms the bank panel's new-content detector — notified BEFORE the + // reapply so re-arm and model restore ride the one load event (otherwise + // pre-existing tracks can be mis-detected as "new" and mass-tagged). 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. + // Reconcile lane ownership against the live project's lanes (P_LANENAME, + // the cross-session source of truth) BEFORE reapplying visibility. Never + // re-mints, never mass-tags. 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 - // an actual change. No-op when the panel is closed. - reasampler::bankPanelRefresh(); + reasampler::bankPanelRefresh(); // cheap fingerprint compare; no-op when unchanged/closed } -// --- projectconfig hook: reload the session on undo/redo (R-B) --------------- -// A Ctrl-Z / Ctrl-Shift-Z rolls back / forward the "reasampler" project ext state on -// disk but keeps the SAME project identity (ReaProject*/GUID/.rpp path), so the timer's -// identity poll reads it as NoOp and never re-reads ext state — the in-memory book/view -// would stay stale until close+reopen. REAPER's projectconfig extension fires -// BeginLoadProjectState on every project-state (re)load, INCLUDING an undo/redo restore -// (isUndo == true for both). We hook it to drive a session reload. +// A Ctrl-Z/Ctrl-Shift-Z rolls back/forward the "reasampler" project ext state on disk +// but keeps the SAME project identity, so the timer's identity poll never re-reads +// ext state on undo/redo — the in-memory book/view would stay stale until +// close+reopen. REAPER's projectconfig fires BeginLoadProjectState on every +// project-state (re)load INCLUDING undo/redo (isUndo == true for both); we hook it. // -// TIMING (the crux): BeginLoadProjectState is documented (reaper_plugin.h ~1203) as -// firing BEFORE any state restore. Reading GetProjExtState synchronously here would -// return the PRE-undo value. So we do NOT read here — we raise a one-shot reload request -// (g_session.requestReload()) that OnTimer's poll() drains on the NEXT tick, by which -// point REAPER has finished restoring the block and GetProjExtState returns -// the POST-undo value. Deterministic, event-driven — NOT ext-state content polling. -// -// GATED ON isUndo: a normal project open also fires BeginLoadProjectState (isUndo=false); -// we ignore that here so a normal open flows solely through the timer's identity-transition -// Load path (no double load). Only undo/redo (isUndo=true) requests the reload. +// TIMING: BeginLoadProjectState fires BEFORE any state restore, so reading +// GetProjExtState here would return the PRE-undo value. Instead we raise a one-shot +// reload request that OnTimer's poll() drains on the NEXT tick, once REAPER has +// finished restoring the block. A normal project open also fires this +// (isUndo=false); ignored here so a normal open flows solely through the timer's +// identity-transition Load path (no double load). static void OnBeginLoadProjectState(bool isUndo, project_config_extension_t* /*reg*/) { if (isUndo) g_session.requestReload(); } -// ProcessExtensionLine / SaveExtensionConfig are intentional no-ops: ReaSampler stores -// its state via project EXT STATE (SetProjExtState/GetProjExtState under "reasampler"), -// which REAPER persists in its own RPP block — NOT via this extension's own -// project lines. We register the struct ONLY for the BeginLoadProjectState undo/redo -// notification. Returning false from ProcessExtensionLine means "not our line" so REAPER -// keeps dispatching (we claim none). SaveExtensionConfig writes nothing. +// Intentional no-ops: ReaSampler stores state via project EXT STATE, not this +// extension's own project lines. The struct is registered ONLY for the +// BeginLoadProjectState undo/redo notification. static bool OnProcessExtensionLine(const char* /*line*/, ProjectStateContext* /*ctx*/, bool /*isUndo*/, project_config_extension_t* /*reg*/) { @@ -257,7 +197,6 @@ static bool OnProcessExtensionLine(const char* /*line*/, ProjectStateContext* /* static void OnSaveExtensionConfig(ProjectStateContext* /*ctx*/, bool /*isUndo*/, project_config_extension_t* /*reg*/) { - // Nothing to write: our data rides in ext state, not project lines. } // Storage must outlive registration — REAPER holds this pointer until we unregister it. @@ -268,19 +207,15 @@ static project_config_extension_t g_projectConfig{ nullptr, // userData }; -// REAPER calls this for EVERY action fired anywhere; claim only our own id, -// return false otherwise so REAPER keeps looking. This TU's own family dispatches -// through the registration table; the Q-W4 families claim their own ids after it. +// REAPER calls this for EVERY action fired anywhere; claim only our own id, return +// false otherwise so REAPER keeps looking. This TU's own family dispatches through +// the registration table; the other families claim their own ids after it. static bool OnHookCommand(int command, int /*flag*/) { if (command == 0) return false; if (reasampler::actionTableHandleCommand(command)) return true; - // Design View action family (D4). Claims only its own ids; returns false for the - // rest so this hook keeps looking (per the contract). if (reasampler::designViewHandleCommand(command)) return true; - // Multi-bank action family (B3). Same contract: claims only its own ids. if (reasampler::bankHandleCommand(command)) return true; - // S8 ingest action family (Media-Explorer import). Same contract. if (reasampler::ingestHandleCommand(command)) return true; return false; } @@ -299,39 +234,30 @@ extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT( { if (!rec) { - // rec == nullptr => REAPER is UNLOADING us. Mirror-unregister every - // callback with the same strings prefixed '-' (per the contract). + // rec == nullptr => REAPER is UNLOADING us. if (g_rec) { // Abort any in-flight realtime capture FIRST, while the API pointers are - // still live — finalize-or-abort + restore so we never leave a temp track, - // an armed track, or an altered transport/cursor in the user's project on - // unload. Commit whatever was captured (best effort) before tearing down. + // still live, so we never leave a temp track, an armed track, or an + // altered transport/cursor in the user's project on unload. capture::AbortRealtimeCaptureForUnload(g_session); g_rec->Register("-timer", (void*)&OnTimer); g_rec->Register("-projectconfig", (void*)&g_projectConfig); g_rec->Register("-toggleaction", (void*)&OnToggleAction); g_rec->Register("-hookcommand", (void*)&OnHookCommand); - // Tear down the Design View action family (D4) — mirror-unregisters each - // gaccel + command_id with '-'-prefixed strings. After the hook is gone. reasampler::designViewUnregisterActions(g_rec); - // Tear down the multi-bank action family (B3) — same mirror-unregister. reasampler::bankUnregisterActions(g_rec); - // Tear down the S8 ingest action family — same mirror-unregister. reasampler::ingestUnregisterActions(g_rec); - // Tear down this TU's own family from the registration table (reverse - // table order; each '-command_id' re-presents the SAME interned, - // channel-qualified pointer used at register). + // This TU's own family, reverse table order; each '-command_id' + // re-presents the SAME interned pointer used at register. reasampler::unregisterActionTable(g_rec); // Retire the REMOVED command ids (command_id only — we never held a gaccel - // for them this session). Clears stale user keybindings on unload. Composed - // per channel so a beta clears beta-qualified retired ids, stable its own. + // for them this session). for (const char* suffix : kRetiredCaptureCmdSuffixes) g_rec->Register("-command_id", (void*)reasampler::channelIdFor(suffix)); } - // Destroy the docked window and release cached thumbnails before we drop - // the API pointers (DockWindowRemove/DestroyWindow need them live). + // Before dropping the API pointers: DockWindowRemove/DestroyWindow need them live. reasampler::bankPanelShutdown(); g_rec = nullptr; return 0; @@ -349,13 +275,10 @@ extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT( g_hInst = hInstance; g_rec = rec; - // Point the bank panel at the live session BEFORE registering its action, so - // a toggle firing immediately has a session to read (M5). Does not open the - // window — only stores the session pointer. + // Point the bank panel at the live session BEFORE registering its action, so a + // toggle firing immediately has a session to read. Does not open the window. reasampler::bankPanelInit(&g_session); - // Register this TU's whole action family from the table: command_id -> gaccel - // per row, all channel-qualified, all FOREVER-STABLE per channel. { const std::vector rows = buildMainActionTable(); reasampler::registerActionTable(rec, rows.data(), rows.size()); @@ -367,37 +290,23 @@ extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT( if (g_cmdToggleBankPanel) rec->Register("toggleaction", (void*)&OnToggleAction); - // Register the Design View action family (D4): toggle/activate mode, tag/untag/ - // show-both selected tracks. Each mints its own command_id + gaccel; the single - // hookcommand below routes them via designViewHandleCommand. Registered before - // the hook so every id is minted first. + // Each family mints its own command_id + gaccel, shares g_session, and is routed + // by the same hookcommand below. Registered before the hook so every id is + // minted first. reasampler::designViewRegisterActions(rec, &g_session); - - // Register the multi-bank action family (B3): create/rename/delete/evacuate bank, - // activate (cycle + pool), move/copy selected samples to a bank, and the two - // full-height layout toggles. Shares g_session with the Design View family; routed - // by the same hookcommand via bankHandleCommand. Registered before the hook. reasampler::bankRegisterActions(rec, &g_session); - - // Register the S8 ingest action family: the Media-Explorer import-into-bank+assign - // action. Shares g_session with the other families; routed by the same hookcommand via - // ingestHandleCommand. (The arrange capture+assign action is a table row above; the - // drop path is a bank_panel callback, not a bindable action.) reasampler::ingestRegisterActions(rec, &g_session); - // One hookcommand routes every ReaSampler action (table + the three families). - // Registered once, after all command ids are minted. rec->Register("hookcommand", (void*)&OnHookCommand); - // Drive project-load / Save-As detection (M4 persist). The timer polls the - // active project each tick; on a project load it reloads the bank from ext - // state, on a Save-As it relocates the bank folder under the new .rpp. + // Drives project-load / Save-As detection: the timer polls the active project + // each tick; on a project load it reloads the bank from ext state, on a Save-As + // it relocates the bank folder under the new .rpp. rec->Register("timer", (void*)&OnTimer); - // Register the projectconfig hook so an UNDO/REDO state restore reloads the - // session's book + view from the restored ext state (R-B). The timer's identity - // poll cannot see an undo (same project identity), so this hook owns undo/redo; it - // requests a deferred reload that the next timer tick drains (see the hook comment). + // An UNDO/REDO state restore reloads the session's book + view from the restored + // ext state. The timer's identity poll cannot see an undo (same project + // identity), so this hook owns it (see OnBeginLoadProjectState). rec->Register("projectconfig", (void*)&g_projectConfig); return 1; // success — REAPER keeps us loaded diff --git a/src/core/audio/peaks.cpp b/src/core/audio/peaks.cpp index 1d79625..034bec3 100644 --- a/src/core/audio/peaks.cpp +++ b/src/core/audio/peaks.cpp @@ -5,14 +5,11 @@ #include #include -// peaks implementation. +// peaks — pure implementation. See peaks.h. // -// One linear pass per channel. The frame->bin partition is computed with integer -// arithmetic so it is exact for any frameCount / binCount pairing: bin b owns the -// half-open frame span [b*frameCount/binCount, (b+1)*frameCount/binCount). That -// span formula distributes the remainder deterministically (earlier bins get the -// extra frames) with no rounding drift and no dropped tail — the last bin's end is -// always exactly frameCount. +// One linear pass per channel. Frame->bin partition uses integer arithmetic so it's exact for +// any frameCount/binCount pairing: bin b owns [b*frameCount/binCount, (b+1)*frameCount/binCount) +// — earlier bins absorb the remainder, no rounding drift, no dropped tail. namespace reasampler::audio { @@ -22,11 +19,10 @@ Envelope computeEnvelope(const std::vector& interleaved, std::size_t binCount) { Envelope envelope(channelCount); if (channelCount == 0) { - return envelope; // no channels -> no envelopes + return envelope; } - // Never read past what the buffer actually holds, even if the caller's - // frameCount overstates the buffer (defensive: no OOB on a short buffer). + // Never read past what the buffer actually holds, even if frameCount overstates it. const std::size_t availableFrames = interleaved.size() / channelCount; const std::size_t frames = std::min(frameCount, availableFrames); @@ -35,14 +31,10 @@ Envelope computeEnvelope(const std::vector& interleaved, bins.assign(binCount, MinMax{}); // empty/degenerate bins default to {0,0} for (std::size_t b = 0; b < binCount; ++b) { - // Half-open frame span for this bin: [b*frames/binCount, (b+1)*frames/binCount). - // Guard against size_t overflow in b*frames and (b+1)*frames: binCount is - // caller-controlled and unbounded, so when b >= SIZE_MAX/frames either - // multiplication could wrap. Any such bin is unreachable in practice - // (allocating that many MinMax entries would OOM first), but we guard - // explicitly to eliminate UB. + // Guard b*frames / (b+1)*frames overflow: binCount is caller-controlled and + // unbounded. Unreachable in practice (would OOM first) but guarded to avoid UB. if (frames > 0 && b >= SIZE_MAX / frames) { - continue; // b*frames or (b+1)*frames would overflow; span is empty + continue; } const std::size_t begin = (b * frames) / binCount; const std::size_t end = ((b + 1) * frames) / binCount; @@ -69,21 +61,17 @@ MinMax columnMinMax(const ChannelEnvelope& bins, int columnCount, int col) { const int nbins = static_cast(bins.size()); if (columnCount <= 0 || nbins == 0) return MinMax{}; - // Clamp col to [0, columnCount-1]. if (col < 0) col = 0; if (col >= columnCount) col = columnCount - 1; - // Half-open bin range for this column, mirroring computeEnvelope's exact partition. - // 64-bit products: col*nbins can exceed int range for a large oversampled envelope - // (same overflow discipline as computeEnvelope's frame-span arithmetic above). + // Half-open bin range for this column, mirroring computeEnvelope's partition. 64-bit + // products: col*nbins can exceed int range for a large oversampled envelope. const std::int64_t begin64 = (static_cast(col) * nbins) / columnCount; const std::int64_t end64 = (static_cast(col) + 1) * nbins / columnCount; - // col <= columnCount-1 guarantees begin64 <= (columnCount-1)*nbins/columnCount < nbins. const int colBinBegin = static_cast(begin64); - // When the column spans no full bin (more columns than bins), use the enclosing bin - // so no column is left empty. + // When the column spans no full bin (more columns than bins), use the enclosing bin. const int scanEnd = (end64 > begin64) ? static_cast(end64) : colBinBegin + 1; const int clampedEnd = (scanEnd <= nbins) ? scanEnd : nbins; @@ -102,14 +90,11 @@ std::size_t lastFrameAboveThreshold(const std::vector& interleaved, 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. + // Scan backward; `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; diff --git a/src/core/audio/peaks.h b/src/core/audio/peaks.h index 6f3d5b6..7cae63e 100644 --- a/src/core/audio/peaks.h +++ b/src/core/audio/peaks.h @@ -1,32 +1,20 @@ #pragma once -// peaks — waveform min/max envelope (thumbnail) computation from raw interleaved -// PCM. We compute our own thumbnails from the captured file rather than depending -// on REAPER's peak API: we own the file format, so this is simpler, testable, and -// dependency-free. A future bank panel (M5) calls this at whatever bin resolution -// the panel width dictates and draws one min/max envelope per channel. -// -// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO -// vendor/ includes. Standard library only. Builds and unit-tests without REAPER. +// peaks — waveform min/max envelope (thumbnail) computation from raw interleaved PCM. We compute +// our own thumbnails rather than depending on REAPER's peak API: we own the file format, so this +// is simpler, testable, and dependency-free. #include #include namespace reasampler::audio { -// Canonical in-memory audio-sample type. `float` is REAPER's native audio buffer -// format (its render/PCM_source callbacks hand back interleaved 32-bit float), so -// peaks consumes that directly with no lossy conversion. If a capture ever lands -// as a different depth, the caller converts to float at the boundary — the -// thumbnail core stays single-typed. -// -// NAMED AudioSample, not `Sample`: `reasampler::Sample` is already bank_model's -// metadata struct. A `using Sample = float` here would collide at namespace scope -// wherever both headers are visible (the bank_panel module includes both). The -// audio-domain name also reads more precisely — this is one PCM sample value. +// REAPER's native audio buffer format (interleaved 32-bit float), consumed directly with no +// lossy conversion. Named AudioSample rather than Sample to avoid colliding with bank_model's +// metadata struct of the same short name. using AudioSample = float; -// One bin of a channel's envelope: the extremes of every sample that fell in it. -// min <= max always. For an empty bin (more bins than frames), both are 0. +// One bin's extremes across the samples that fell in it. min <= max always; an empty bin +// (more bins than frames) is {0, 0}. struct MinMax { AudioSample min = 0.0f; AudioSample max = 0.0f; @@ -37,83 +25,61 @@ struct MinMax { // One channel's envelope: exactly `binCount` bins, in time order. using ChannelEnvelope = std::vector; -// Per-channel envelopes: outer index is channel (channelCount entries, order -// preserved — never mixed or folded), inner is that channel's bins. +// Per-channel envelopes: outer index is channel (channelCount entries, order preserved — never +// mixed or folded), inner is that channel's bins. using Envelope = std::vector; // Computes a per-channel min/max envelope from interleaved PCM. // -// interleaved frame-interleaved samples: [f0c0, f0c1, ..., f1c0, f1c1, ...]. -// Size must be >= frameCount * channelCount; extra is ignored. -// channelCount channels per frame (the stride). Each channel is enveloped -// INDEPENDENTLY — no averaging, no stereo fold (precision -// invariant: channel count preserved). +// interleaved frame-interleaved samples: [f0c0, f0c1, ..., f1c0, f1c1, ...]. Size must be +// >= frameCount * channelCount; extra is ignored. +// channelCount channels per frame (the stride). Each channel is enveloped INDEPENDENTLY — no +// averaging, no stereo fold (channel count is preserved end to end). // frameCount frames (samples-per-channel) to consider. // binCount requested bins per channel. Honored exactly for any frameCount. // -// Frame->bin partition: frames are split into `binCount` contiguous spans as -// evenly as possible; when frameCount does not divide evenly, the remainder is -// spread one-frame-per-bin across the earliest bins (ceil/floor split), so the -// tail is never dropped and no bin reads out of bounds. When binCount > frameCount -// the trailing empty bins are {0, 0}. +// Frame->bin partition: frames split into `binCount` contiguous spans as evenly as possible; +// when frameCount doesn't divide evenly, the remainder spreads one-frame-per-bin across the +// earliest bins, so the tail is never dropped and no bin reads out of bounds. // -// Defined behavior for degenerate input (no UB, no throw): -// binCount == 0 -> per channel: an empty bin vector. -// channelCount == 0 -> an empty envelope (no channels). -// frameCount == 0 -> per channel: binCount bins, all {0, 0}. +// Degenerate input (no UB, no throw): binCount == 0 -> empty bin vector per channel; +// channelCount == 0 -> empty envelope; frameCount == 0 -> binCount bins, all {0, 0}. Envelope computeEnvelope(const std::vector& interleaved, std::size_t channelCount, std::size_t frameCount, std::size_t binCount); -// The merged min/max for display column `col` (0-based, of `columnCount` total columns) -// of a pre-computed per-bin ChannelEnvelope: the true extremes of every bin that projects -// to that column. This is the display-side collapse of an envelope computed at HIGHER -// resolution than the drawn width (oversampled bins -> per-pixel-column min/max), so a -// steep transient whose adjacent bins hold disjoint spans (e.g. {0.9,1.0} then -// {-1.0,-0.9}) renders as one gap-free vertical span instead of two separated dots. +// Merged min/max for display column `col` (0-based, of `columnCount` total) of a pre-computed +// ChannelEnvelope — the true extremes of every bin projecting to that column. This is the +// display-side collapse when the envelope was computed at a higher resolution than the drawn +// width, so a steep transient split across adjacent bins (e.g. {0.9,1.0} then {-1.0,-0.9}) +// renders as one gap-free span instead of two separated dots. // -// Bin->column mapping mirrors computeEnvelope's half-open partition: -// column col owns bins [col*nbins/columnCount, (col+1)*nbins/columnCount). -// When that range is empty (more columns than bins), the enclosing bin -// (col*nbins/columnCount) fills the column — so no column is left empty and no bin is -// ever dropped. columnCount <= 0 or bins.empty() returns {0, 0}; `col` is clamped to -// [0, columnCount-1]. Pure. +// Bin->column mapping mirrors computeEnvelope's half-open partition: column col owns bins +// [col*nbins/columnCount, (col+1)*nbins/columnCount). When that range is empty (more columns +// than bins), the enclosing bin fills the column instead. columnCount <= 0 or bins.empty() +// returns {0, 0}; col is clamped to [0, columnCount-1]. MinMax columnMinMax(const ChannelEnvelope& bins, int columnCount, int col); -// 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. +// Sentinel for "no frame in the scanned range peaked above threshold". SIZE_MAX is unambiguous +// since no real frame index can reach it. inline constexpr std::size_t kNoFrameAboveThreshold = static_cast(-1); -// Scans interleaved PCM BACKWARD for the last frame whose per-frame peak (the max -// absolute value across all channels of that frame — NO stereo fold, just the -// loudest channel that frame) exceeds `linearThreshold`, returning that frame index. -// Returns kNoFrameAboveThreshold if no frame exceeds it (or on degenerate input). +// Scans interleaved PCM BACKWARD for the last frame whose per-frame peak (max |sample| across +// all channels of that frame — no stereo fold) exceeds `linearThreshold`. 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). +// This is the boundary primitive behind the realtime tail's decay-scan trim (see +// docs/product/capture-tail.md): the recorded tail is scanned back from the end for the last +// frame still above -72 dB, and the file truncated one frame past it. Deliberately separate from +// computeEnvelope — that answers "the min/max envelope over bins" (a thumbnail), this answers +// "the last frame above a level" (a boundary); bending a bin-oriented envelope to a frame-exact +// question is a worse fit. // -// 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. +// linearThreshold 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 greater than this. std::size_t lastFrameAboveThreshold(const std::vector& interleaved, std::size_t channelCount, std::size_t frameCount, diff --git a/src/core/capture/batch_capture.cpp b/src/core/capture/batch_capture.cpp index 3f5ff1d..f6ff1c8 100644 --- a/src/core/capture/batch_capture.cpp +++ b/src/core/capture/batch_capture.cpp @@ -1,5 +1,5 @@ -// batch_capture.cpp — pure logic for M11 batch capture. See header. -// NO REAPER types; unit-tested by tests/test_batch_capture.cpp. +// batch_capture.cpp — pure logic for batch capture. See header. +// Unit-tested by tests/test_batch_capture.cpp. #include "core/capture/batch_capture.h" @@ -12,9 +12,7 @@ std::vector planCaptureUnits(const std::vector& ranges) units.reserve(ranges.size()); int ordinal = 0; for (const BatchRange& r : ranges) { - // Drop empty/inverted ranges — the offline backend refuses end<=start too, so - // planning one would only manufacture a guaranteed per-unit failure. Ordinals - // count kept units so the reported numbering is contiguous. + // Drop empty/inverted ranges — the offline backend refuses end<=start too. if (!(r.endSeconds > r.startSeconds)) continue; ++ordinal; units.push_back({ordinal, r.startSeconds, r.endSeconds}); diff --git a/src/core/capture/batch_capture.h b/src/core/capture/batch_capture.h index e1353a7..392b992 100644 --- a/src/core/capture/batch_capture.h +++ b/src/core/capture/batch_capture.h @@ -1,29 +1,24 @@ #pragma once -// batch_capture — the REAPER-free logic behind M11 batch capture (one action fires -// N captures: one bank sample per selected item / per razor area). +// batch_capture — the REAPER-free logic behind batch capture (one action fires N +// captures: one bank sample per selected item / per razor area). // -// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO -// vendor/ includes. Standard library only. The batch shell (main.cpp) reads the DAW -// state (selected items -> their exact bounds; every track's P_RAZOREDITS -> areas) -// and hands the raw ranges here so the genuinely-pure, easy-to-get-wrong pieces are -// unit-tested outside the DAW: +// PURE MODULE: NO REAPER types, NO SWELL, NO vendor/ includes. Standard library +// only. The batch shell reads the DAW state (selected items -> exact bounds; +// each track's P_RAZOREDITS -> areas) and hands the raw ranges here: // -// 1. planCaptureUnits: an ordered list of (start,end) source ranges -> an ordered -// list of CaptureUnit, each carrying its 1-based ordinal and validated bounds. -// Empty/inverted ranges are DROPPED (mirrors the offline backend's own -// end>start guard) so a zero-length item/area never produces a stray render. -// Order is preserved: unit ordinals count only the KEPT units, so a batch of -// three valid items yields ordinals 1,2,3 regardless of dropped neighbors. -// 2. BatchOutcome: order-preserving aggregation of per-unit results into a summary -// (succeeded / failed counts + the ordered list of failures) so the shell can -// report a mixed result with one console line and no partial-corruption -// ambiguity. The AGGREGATION is pure; the render loop that feeds it is shell. +// 1. planCaptureUnits: an ordered list of (start,end) ranges -> an ordered +// list of CaptureUnit, each with a 1-based ordinal and validated bounds. +// Empty/inverted ranges are dropped (mirrors the offline backend's own +// end>start guard); ordinals count only the kept units, so three valid +// items yield 1,2,3 regardless of dropped neighbors. +// 2. BatchOutcome: order-preserving aggregation of per-unit results into a +// summary (succeeded/failed counts + ordered failures) for one console +// line with no partial-corruption ambiguity. // -// Range is the ONLY thing that varies per unit here. FX scope (item vs track) is a -// per-ACTION constant the shell already owns (fxBypassPlanFor); it is not a -// per-unit field. Item-batch uses item scope; razor-batch uses track scope — the -// shell passes the scope straight through to each render, unchanged from the -// single-capture path. +// Range is the only thing that varies per unit here. FX scope (item vs track) is +// a per-action constant the shell already owns; item-batch uses item scope, +// razor-batch uses track scope, passed through unchanged from the single-capture +// path. #include #include @@ -31,10 +26,10 @@ namespace reasampler::capture { -// One capture in a batch: an exact source range plus its 1-based ordinal within the -// KEPT set. The ordinal disambiguates per-unit file stems (the offline backend's -// unique tag is 1-second-granular, so a fast batch could otherwise collide N files -// onto one name) and labels a failure in the summary. +// One capture in a batch: an exact source range plus its 1-based ordinal within +// the kept set. The ordinal disambiguates per-unit file stems (the offline +// backend's unique tag is 1-second-granular, so a fast batch could otherwise +// collide N files onto one name) and labels a failure in the summary. struct CaptureUnit { int ordinal = 0; // 1-based, counts kept units only double startSeconds = 0.0; // exact — no rounding @@ -42,20 +37,18 @@ struct CaptureUnit { }; // A source range handed in by the shell (a selected item's [pos, pos+len] or one -// razor area's [start, end]). Kept as a distinct type from CaptureUnit so the input -// (raw, possibly-invalid) and the output (validated, ordinal-assigned) do not share -// a shape by accident. Named BatchRange (not SourceRange) to avoid collision with -// bank_model's SourceRange, which carries PPQ fields this planner does not need. +// razor area's [start, end]). Named BatchRange (not SourceRange) to avoid +// collision with bank_model's SourceRange, which carries PPQ fields this planner +// doesn't need. struct BatchRange { double startSeconds = 0.0; double endSeconds = 0.0; }; // Validates + orders a batch's source ranges into capture units. Preserves input -// order; DROPS every range with end <= start (empty/inverted) so no stray render is -// planned; assigns 1-based ordinals over the KEPT units. An empty input (no selected -// item / no razor area) yields an empty plan — the shell reports "nothing to batch" -// and writes nothing (the same no-op posture the single-capture path takes). +// order; drops every range with end <= start; assigns 1-based ordinals over the +// kept units. An empty input yields an empty plan — the shell reports "nothing +// to batch" and writes nothing. std::vector planCaptureUnits(const std::vector& ranges); // The per-unit verdict the shell records after each render attempt, in unit order. @@ -65,10 +58,9 @@ struct BatchUnitResult { std::string detail; // failure reason (empty on success) — for the summary }; -// Order-preserving aggregation of a batch's per-unit results. Built incrementally by -// the shell (record() after each unit) so a mid-batch failure is captured without -// aborting the remaining units (no partial corruption: each unit is independent, and -// the selection is restored on every exit path by the shell's RAII guard). +// Order-preserving aggregation of a batch's per-unit results. Built incrementally +// by the shell (record() after each unit) so a mid-batch failure doesn't abort +// the remaining units — each unit is independent. class BatchOutcome { public: // Records one unit's verdict. Order of calls IS the reported order. diff --git a/src/core/capture/capture_paths.cpp b/src/core/capture/capture_paths.cpp index 575af3e..5d50501 100644 --- a/src/core/capture/capture_paths.cpp +++ b/src/core/capture/capture_paths.cpp @@ -6,25 +6,16 @@ namespace reasampler::capture { -// The content-identity hashes (hashBytes / hashWavContent) moved to wav_codec -// (Q-W3, audit §4e) — one pure owner of the RIFF chunk walk, shared with the -// layout parse so hashing and decoding cannot desynchronize. - std::string normalizeSlashes(const std::string& path) { std::string out = path; for (char& c : out) { if (c == '\\') c = '/'; } - // Strip a single trailing slash so joins do not double up. Preserve a lone - // "/" (root) — stripping it would turn root into empty. + // Strip a trailing slash but preserve a lone "/" (root). if (out.size() > 1 && out.back() == '/') { out.pop_back(); } #ifdef _WIN32 - // Windows paths are case-insensitive. Fold to lowercase so that two paths - // that differ only in drive-letter or component casing compare equal (e.g. - // "C:/Foo/BAR.wav" == "c:/foo/bar.wav"). On macOS/Linux, exact case is - // preserved (the filesystem is case-sensitive; folding would be wrong). for (char& c : out) c = static_cast(std::tolower(static_cast(c))); #endif return out; @@ -39,8 +30,7 @@ std::string sanitizeStem(const std::string& baseName) { c == '-'; out.push_back(keep ? static_cast(c) : '_'); } - // Collapse to a stable default if nothing usable survived (e.g. all spaces). - // A stem of only separators ('.', '_', '-') is also unhelpful as a name. + // Collapse to a stable default if nothing alnum survived. bool hasAlnum = false; for (unsigned char c : out) { if ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || @@ -66,21 +56,16 @@ BankPaths deriveBankPaths(const std::string& projectDir, } const std::string fileName = stem + ".wav"; - // Precondition: the capture shell must resolve a non-empty project directory - // before calling this function. An empty projectDir would produce a bare - // relative "reasampler_bank" path — the silent default-location fallback this - // tool explicitly forbids. Assert in debug; leave absoluteDir empty in release - // so any caller that ignores the precondition fails loudly at the render/stat - // step rather than silently writing to CWD. + // Precondition: caller must resolve a non-empty project directory — an + // empty one would otherwise fall back to a bare relative path (forbidden). + // Assert in debug; leave absoluteDir empty in release so a caller that + // ignores it fails at the render/stat step, not silently onto CWD. assert(!dir.empty() && "deriveBankPaths: projectDir must not be empty"); BankPaths p; p.fileStem = stem; // stem only — REAPER appends extension p.fileName = fileName; p.relativePath = std::string(kBankSubfolder) + "/" + fileName; - // absoluteDir intentionally omits a trailing slash (RENDER_FILE wants the - // directory itself; RENDER_PATTERN supplies the file name separately). - // Empty when precondition is violated (dir empty) — caller must not proceed. p.absoluteDir = dir.empty() ? std::string{} : dir + "/" + kBankSubfolder; return p; @@ -88,15 +73,12 @@ BankPaths deriveBankPaths(const std::string& projectDir, std::string bankRelativeForName(const std::string& fileName) { if (fileName.empty()) return {}; - // The SAME expression deriveBankPaths uses for relativePath, kept in one place so - // the two spellings can never drift (Phase R spelling-consistency invariant). + // Same expression deriveBankPaths uses, so the two spellings can't drift. return std::string(kBankSubfolder) + "/" + fileName; } std::string resolveBankFile(const std::string& projectDir, const std::string& relativePath) { - // No default-location fallback (CLAUDE.md invariant): an empty project dir or - // relative path yields empty, not a bare relative path resolved against CWD. if (projectDir.empty() || relativePath.empty()) { return {}; } @@ -109,10 +91,7 @@ std::string resolveBankFile(const std::string& projectDir, } std::string projectDirOfRpp(const std::string& rppPath) { - // An unsaved project reports an empty .rpp path; keep it empty so downstream - // resolution refuses (no default-location fallback). Mirrors the former persist shell's - // projectDirOf exactly: parent_path of the .rpp, then normalizeSlashes. - if (rppPath.empty()) return {}; + if (rppPath.empty()) return {}; // unsaved project: keep empty, no fallback std::string dir = std::filesystem::path(rppPath).parent_path().string(); return normalizeSlashes(dir); } @@ -128,9 +107,7 @@ BankRelocation deriveRelocationPlan(const std::string& oldProjectDir, r.oldBankDir = oldDir + "/" + kBankSubfolder; r.newBankDir = newDir + "/" + kBankSubfolder; - // A Save (in place) leaves the project dir unchanged — nothing to relocate. - // Only a Save-As to a different directory needs the bank moved. - r.needed = (oldDir != newDir); + r.needed = (oldDir != newDir); // Save-in-place leaves the dir unchanged return r; } @@ -139,42 +116,16 @@ ProjectTransition classifyProjectTransition(bool sameProjectObject, const std::string& lastPath, const std::string& currentGuid, const std::string& currentPath) { - // 1. The GUID is the identity of record and is checked FIRST. A different - // stored GUID means a genuinely different project is active — Load ITS index. - // This catches the regression that pointer-primary classification missed: - // REAPER RECYCLES ReaProject* addresses across close/open, so a reopened / - // new project can reuse the previous project's address (sameProjectObject == - // true) while carrying a different stored GUID. Deciding on the pointer alone - // then returned NoOp/SaveAsRelocate and the bank never reloaded. The GUID is - // immune to address recycling, so it leads. Also covers new/unsaved<->saved - // transitions (one GUID empty, the other not) and switching between two - // distinct saved projects. + // See capture_paths.h for the GUID-primary rationale and rule order. if (currentGuid != lastGuid) { return ProjectTransition::Load; } - - // From here currentGuid == lastGuid (they are equal; both may be empty for - // unsaved projects). The pointer now disambiguates the same-GUID case. - - // 2. Same GUID but a DIFFERENT object is a forked sibling: Save-As copied our - // GUID onto a distinct project object. Load its (own) index; never relocate. - // Two unsaved projects (both GUIDs empty, distinct objects) also land here — - // Load, so switching between them installs the right in-memory state. if (!sameProjectObject) { - return ProjectTransition::Load; + return ProjectTransition::Load; // forked sibling: same GUID, different object } - - // 3. Same object AND same GUID with a NEW path is a genuine Save-As (the object - // identity is proven and the record identity is unchanged — only the .rpp - // moved). Also the first save of an unsaved project (both GUIDs empty, old - // path empty): SaveAsRelocate is safe there because deriveRelocationPlan - // no-ops on the empty old dir (empty-GUID safety preserved) while poll() - // mints a GUID. if (currentPath != lastPath) { return ProjectTransition::SaveAsRelocate; } - - // 4. Same object, same GUID, same path — Save in place / idle tick. return ProjectTransition::NoOp; } diff --git a/src/core/capture/capture_paths.h b/src/core/capture/capture_paths.h index bd188e3..8ca0157 100644 --- a/src/core/capture/capture_paths.h +++ b/src/core/capture/capture_paths.h @@ -1,16 +1,8 @@ #pragma once -// capture_paths — the REAPER-free path arithmetic behind offline capture. -// -// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO -// vendor/ includes. Standard library only. The capture shell resolves the -// current project directory via REAPER APIs, then hands the raw strings here so -// the fiddly, easy-to-get-wrong path arithmetic (bank subfolder, unique file -// name, absolute render dir, project-relative index path) is unit-tested outside -// the DAW. -// -// Path convention: this module works in forward-slash form and does NOT touch -// the filesystem. The bank subfolder name is a fixed constant so the same -// project always resolves the same bank location (determinism). +// capture_paths — the REAPER-free path arithmetic behind offline capture. The +// capture shell resolves the current project directory via REAPER APIs, then +// hands the raw strings here. Forward-slash form throughout, no filesystem +// access; the bank subfolder name is a fixed constant. #include #include @@ -20,7 +12,7 @@ namespace reasampler::capture { // The project-relative bank subfolder. All captured wavs live here so the bank -// travels with the .rpp (CONTEXT.md §Settled decisions: per-project bank). +// travels with the .rpp. inline constexpr const char* kBankSubfolder = "reasampler_bank"; // A resolved pair of paths for one capture: where REAPER must be told to write @@ -34,150 +26,87 @@ struct BankPaths { std::string fileStem; // (RENDER_PATTERN — REAPER appends the extension) }; -// NOTE (Q-W3, audit §4e): the content-identity hashes (hashBytes / hashWavContent) -// moved to core/capture/wav_codec.{h,cpp} — the ONE pure owner of the WAV/RIFF byte -// format — so this module holds path arithmetic only, with no RIFF chunk knowledge. - -// Normalizes a path to forward slashes and strips any trailing slash. Empty in -// -> empty out. Pure string transform (does not consult the filesystem). -// Platform case rule: on Windows (_WIN32) the result is also lowercased so that -// paths differing only in drive-letter or component casing compare equal (Windows -// paths are case-insensitive). On macOS/Linux the case is preserved exactly (those -// filesystems are case-sensitive). +// Normalizes a path to forward slashes and strips any trailing slash (does not +// consult the filesystem). On Windows (_WIN32) also lowercases the result so +// paths differing only in casing compare equal; macOS/Linux preserve case. std::string normalizeSlashes(const std::string& path); // Sanitizes a caller-supplied base name into a filesystem-safe stem: keeps -// [A-Za-z0-9._-], replaces every other byte (spaces, slashes, quotes, control) -// with '_', and collapses to "capture" if nothing usable remains. Deterministic: -// the same input always yields the same stem (feeds bit-identical file naming). +// [A-Za-z0-9._-], replaces every other byte with '_', and collapses to +// "capture" if nothing usable remains. Deterministic. std::string sanitizeStem(const std::string& baseName); -// Derives the bank paths for one capture. -// projectDir : absolute directory of the current .rpp (any slash style) -// baseName : human base for the file stem (sanitized) -// uniqueTag : caller-supplied disambiguator appended to the stem (e.g. a -// timestamp or counter) so repeated captures do not collide. -// Also sanitized. May be empty. -// Produces "[_].wav". The relativePath is always project-relative and -// forward-slashed so it satisfies BankModel::add's relative-only invariant. +// Derives the bank paths for one capture: baseName is the sanitized file-stem +// source, uniqueTag an optional sanitized disambiguator (timestamp/counter) so +// repeated captures don't collide. Produces "[_].wav". BankPaths deriveBankPaths(const std::string& projectDir, const std::string& baseName, const std::string& uniqueTag); -// The project-relative index spelling for a bank file KNOWN ONLY by its file name — -// the forward derivation the Phase R prune shell uses to spell an ENUMERATED folder -// entry the SAME way deriveBankPaths spelled it at capture time. By construction it -// is the identical expression deriveBankPaths().relativePath uses (kBankSubfolder + -// "/" + fileName), so a file the capture path created and a directory listing of that -// same file resolve to the byte-identical relative string — the safety-critical -// spelling-consistency the prune core's exact-string match depends on (a divergence -// here could make a referenced file look like an orphan). fileName is a bare entry -// name (no directory component); the caller supplies forward-slash-free names from the -// folder enumeration. Empty in -> empty out. +// The project-relative index spelling for a bank file known only by its file +// name (bare entry, no directory) — the prune shell uses this to spell an +// enumerated folder entry the SAME way deriveBankPaths spelled it at capture +// time; a divergence here could make a referenced file look like an orphan. std::string bankRelativeForName(const std::string& fileName); -// --- Persist-side path arithmetic (M4) -------------------------------------- +// --- Persist-side path arithmetic ------------------------------------------- // -// The index stores relative paths only; on project load the persist shell must -// turn each entry's relativePath back into an absolute path against the CURRENT -// project directory (so a project opened from a new location still resolves its -// bank). This is the inverse of the relativePath the capture path produced. -// -// projectDir : absolute directory of the current .rpp (any slash style) -// relativePath : a project-relative index entry (e.g. "reasampler_bank/x.wav") -// -// Returns "/" forward-slashed. Returns empty when -// either input is empty (no default-location fallback — CLAUDE.md invariant) so -// a caller that ignores an unsaved/unset project fails loudly rather than -// resolving against CWD. +// The index stores relative paths only; on project load the persist shell +// turns each relativePath back into an absolute path against the current +// project directory — the inverse of deriveBankPaths. + +// Returns "/" forward-slashed, or empty if either +// input is empty (no default-location fallback — an unsaved/unset project +// fails loudly rather than resolving against CWD). std::string resolveBankFile(const std::string& projectDir, const std::string& relativePath); -// The project directory that holds a .rpp: its parent directory, forward-slashed, -// trailing slash stripped. Empty in -> empty out (an unsaved project has an empty -// .rpp path, which must stay empty so resolveBankFile refuses to resolve — the -// no-default-location invariant). This is the M4 convention persist uses to place -// the bank alongside the .rpp; extracted here (pure) so the VST3 instrument resolves -// audio paths the SAME way persist does rather than re-implementing the derivation. +// The project directory that holds a .rpp: parent directory, forward-slashed, +// trailing slash stripped. Empty in -> empty out (an unsaved project reports +// an empty .rpp path). Pure so the VST3 instrument resolves audio paths the +// same way persist does. std::string projectDirOfRpp(const std::string& rppPath); // A relocation plan for the physical bank folder on Save-As to a new project // location. The index's relative paths do NOT change (they are relative to the -// project dir, which is what moved with the .rpp), so relocation is purely a -// folder move: copy/move the whole bank subfolder from the old project dir to -// the new one. Both dirs are absolute, forward-slashed, trailing-slash-stripped. +// project dir, which moved with the .rpp), so relocation is purely a folder +// move. Both dirs are absolute, forward-slashed, trailing-slash-stripped. struct BankRelocation { std::string oldBankDir; // /reasampler_bank std::string newBankDir; // /reasampler_bank bool needed = false; // false when old==new (Save in place, not Save-As) }; -// Derives the relocation plan from the old and new project directories. -// oldProjectDir : project dir the bank currently sits under (any slash style) -// newProjectDir : project dir the .rpp was just saved to (any slash style) -// `needed` is true iff the normalized dirs differ (a genuine Save-As-to-new-dir). -// Returns a plan with empty dirs and needed=false when either input is empty. +// Derives the relocation plan: `needed` is true iff the normalized old/new +// project dirs differ (a genuine Save-As-to-new-dir); empty dirs/needed=false +// when either input is empty. BankRelocation deriveRelocationPlan(const std::string& oldProjectDir, const std::string& newProjectDir); -// --- Project-identity transition (W12 combined identity fix) ----------------- +// --- Project-identity transition --------------------------------------------- // -// What the persist timer must do on each tick. Identity rests on TWO facts, -// layered GUID-PRIMARY: -// 1. the minted GUID — content-based identity of record, stored in ext state. -// It is IMMUNE to REAPER recycling a closed project's ReaProject* address, -// so it is checked FIRST. -// 2. sameProjectObject — did the same live ReaProject* stay active across the -// two ticks (computed in poll() as `proj == lastProject_`)? Used ONLY to -// disambiguate the same-GUID case: a forked sibling (Save-As copied our GUID -// onto a distinct object) vs a genuine Save-As (one object, new path). -// -// This fix layers both prior designs, GUID-primary. M4 (GUID-only) broke Save-As -// forks: Save-As copies the whole .rpp incl. our stored GUID, so a fork and its -// parent share a GUID on disk. W10 (pointer-primary, GUID voided) broke pointer -// RECYCLING: REAPER reuses a closed project's address, so a reopened/new project -// can present the previous project's pointer with a different stored GUID — -// pointer-primary read that as NoOp/SaveAsRelocate and the bank never reloaded. -// Checking the GUID first catches recycling; the pointer then separates a fork -// (same GUID, different object -> Load) from a Save-As (same GUID, same object, -// new path -> relocate). -// -// The load-bearing rule: a DIFFERENT record identity (GUID) is always a Load; a -// DIFFERENT project object with the same GUID is a fork Load, never a relocate. +// What the persist timer must do on each tick. GUID is checked FIRST because +// two prior pointer-primary/GUID-only designs each broke a real case: a +// GUID-only check misreads a Save-As fork as the same project (fork and +// parent share a GUID on disk); a pointer-primary check misreads REAPER +// recycling a closed project's ReaProject* address onto an unrelated project +// (a different project, same recycled pointer, read as NoOp/SaveAsRelocate — +// the bank never reloads). Checking GUID first catches recycling; the pointer +// (sameProjectObject) then separates a forked sibling (Load) from a genuine +// Save-As (SaveAsRelocate). enum class ProjectTransition { NoOp, // same object, same GUID, same location — nothing to do Load, // a different project is active — load ITS index from ext state SaveAsRelocate, // SAME object + SAME GUID, new .rpp location — relocate the bank }; -// Classifies what a poll tick observed. -// sameProjectObject : true iff the SAME ReaProject* stayed active across the two -// ticks (poll() computes `proj == lastProject_`). The pure -// classifier takes the bool, not the raw pointer, to stay -// REAPER-free and testable. -// lastGuid : the GUID of the project persist last acted on ("" if none/unsaved) -// lastPath : that project's .rpp path when last seen ("" if unsaved) -// currentGuid : the GUID stored in the now-active project's ext state ("" if -// unsaved or never written) -// currentPath : the now-active project's .rpp path ("" if unsaved) -// -// Rules (evaluated in EXACTLY this order): -// 1. currentGuid != lastGuid -> Load (different record identity: -// recycled pointer w/ different GUID, -// new/unsaved<->saved, or two distinct -// saved projects) -// 2. !sameProjectObject -> Load (same GUID, different object: -// forked sibling, or two unsaved projects) -// 3. currentPath != lastPath -> SaveAsRelocate (same object + same GUID, -// new path: genuine Save-As, or first save -// of an unsaved project — relocate no-ops -// on the empty old dir, poll() mints a GUID) -// 4. otherwise -> NoOp (same object, same GUID, same path) -// -// The GUID (identity of record) leads; the pointer only disambiguates the same-GUID -// case (fork-Load in step 2 vs Save-As in step 3). The empty-GUID safety (unsaved -// projects never physically relocate) is preserved because an empty old project dir -// makes deriveRelocationPlan's `needed` false. +// Classifies what a poll tick observed. sameProjectObject is passed as a bool +// (not the raw pointer) to keep the classifier REAPER-free and testable; +// lastGuid/lastPath is the project persist last acted on, currentGuid/ +// currentPath the now-active project (both "" if unsaved/unwritten). +// Evaluated in order: currentGuid!=lastGuid -> Load; !sameProjectObject -> +// Load (forked sibling); currentPath!=lastPath -> SaveAsRelocate (also covers +// first save of an unsaved project); else NoOp. ProjectTransition classifyProjectTransition(bool sameProjectObject, const std::string& lastGuid, const std::string& lastPath, diff --git a/src/core/capture/capture_realtime.cpp b/src/core/capture/capture_realtime.cpp index 2ff05e4..eb61f82 100644 --- a/src/core/capture/capture_realtime.cpp +++ b/src/core/capture/capture_realtime.cpp @@ -1,6 +1,5 @@ -// capture_realtime.cpp — pure logic for the realtime-record backend (M8). See -// header. NO REAPER types; unit-tested by tests/test_capture_realtime.cpp. -// (Renamed from realtime_record.cpp in Q-W3 — the Q-9 naming rider.) +// capture_realtime.cpp — pure logic for the realtime-record backend. See header. +// Unit-tested by tests/test_capture_realtime.cpp. #include "core/capture/capture_realtime.h" @@ -9,11 +8,8 @@ namespace reasampler::capture { RecordModePlan recordModePlanFor(int channelCount, OutputTap tap) { RecordModePlan p; - // Stereo vs mono output recording, latency-compensated either way so the - // recorded file lines up with the source. A request asking for <= 1 channel - // records mono-out; anything else records stereo-out. (Higher channel counts - // still record stereo-out here — REAPER's output-record modes are mono/stereo - // only; a >2-channel realtime capture is out of scope for this increment.) + // REAPER's output-record modes are mono/stereo only; >2 channels still + // records stereo-out (a >2-channel realtime capture is out of scope). p.recMode = (channelCount <= 1) ? kRecModeMonoOutLatComp : kRecModeStereoOutLatComp; @@ -26,42 +22,32 @@ RecordModePlan recordModePlanFor(int channelCount, OutputTap tap) { } OutputTap outputTapForWetDry(double wetDry) { - // Fully wet (1.0) taps post-fader; any dry-ward value taps pre-FX — the true - // pre-FX dry that offline render cannot produce (the realtime backend's whole - // reason to exist for the M10 null test). PostFxPreFader is an explicit future - // option, not reachable from the wet/dry axis, so it is not returned here. return (wetDry >= 1.0) ? OutputTap::PostFader : OutputTap::PreFx; } Sample sampleFromRecordedCapture(const RecordedCapture& cap) { Sample s; - // Same id shape as the offline path: "cap--" would need the file - // name; here the recorded file name is the tail of relativePath. Keep the id - // stable + unique via the tag, and include the relative path tail so two - // captures with the same tag (impossible in practice) still differ. + // Relative path tail included so two same-tag captures (shouldn't happen) still differ. s.id = "cap-" + cap.uniqueTag + "-" + cap.relativePath; s.displayName = cap.displayName; - s.relativePath = cap.relativePath; // project-relative (invariant) + s.relativePath = cap.relativePath; s.sourceMode = cap.sourceMode; s.sourceRange.startSeconds = cap.startSeconds; s.sourceRange.endSeconds = cap.endSeconds; - // PPQ/beats deferred (musical-placement concern) — identical to the offline path. + // PPQ/beats deferred (musical-placement concern), as offline. s.wetDry = cap.wetDry; s.trackGuids = cap.trackGuids; s.channelCount = cap.channelCount; s.sampleRate = cap.sampleRate; // 0 when project rate was unknown s.lengthSeconds = cap.endSeconds - cap.startSeconds; s.captureTempo = cap.captureTempo; - s.captureTimeSigNum = cap.captureTimeSigNum; // L7 F1 meter stamp (0/0 = unstamped) + s.captureTimeSigNum = cap.captureTimeSigNum; // 0/0 = unstamped s.captureTimeSigDenom = cap.captureTimeSigDenom; - s.tier = Tier::Scratch; // captures land in scratch by default - // contentHash set by the caller (capture_realtime.cpp) after the file is - // finalized and on disk — the hash is over the finished file bytes. Left empty - // here because sampleFromRecordedCapture runs before the file exists (the - // mapping is pure / DAW-free); the shell patches it in after the move+trim. - // Phase S seam fields (rootNote / loop) left empty (D-B) — same reasoning as the - // offline path: a realtime record of wet output is not a single played note, so - // no root note is derivable; loop points are set by a later explicit action. + s.tier = Tier::Scratch; + // contentHash is left empty: this mapping runs before the file exists on + // disk; the shell patches the hash in after the move+trim. + // rootNote/loop left empty: a realtime record of wet output isn't a single + // played note, so no root note is derivable; loop points are a later action. s.createdTimestamp = cap.createdTimestamp; return s; } @@ -72,20 +58,15 @@ RecordPhase advanceRecordPhase(RecordPhase current, double rangeEndSeconds) { switch (current) { case RecordPhase::Recording: { - // Transport stopped while we still expected to be recording -> the user - // (or REAPER) stopped early. Move to the flush wait and finalize whatever - // was captured up to the stop. + // Stopped early (user or REAPER) -> finalize what was captured so far. if (!inputs.transport.recording) return RecordPhase::Finalizing; - // Reached the range end (latency-compensated play position). >= (not >) - // so a cursor landing exactly on the end completes. + // >= (not >): a cursor landing exactly on the end completes. if (inputs.transport.playPosition >= rangeEndSeconds) return RecordPhase::Finalizing; - // Self-defense (review §3): the transport is running but the play cursor - // is not advancing to the end (stuck / looping). Without this the machine - // stays in Recording forever, leaking the temp track + armed sink. Force - // the flush wait once wall-clock exceeds the nominal duration + margin. + // Self-defense: a stuck/looping transport that never reaches end would + // otherwise stay in Recording forever, leaking the temp track + armed sink. const double ceiling = (rangeEndSeconds - rangeStartSeconds) + kRecordMarginSeconds; if (inputs.elapsedSeconds > ceiling) return RecordPhase::Finalizing; @@ -94,22 +75,16 @@ RecordPhase advanceRecordPhase(RecordPhase current, } case RecordPhase::Finalizing: { - // The transport is stopped; wait for REAPER to flush/close the recorded - // take on the audio thread. Finalize (move + Sample) only once the file - // exists AND is stable (review §2) — moving it early races the flush and - // yields a truncated / missing capture. + // Moving the file before it's stable would race REAPER's flush and + // yield a truncated/missing capture. if (inputs.fileReady) return RecordPhase::Done; - // Bound the wait: a file that never stabilizes fails cleanly rather than - // hanging the in-flight state for the session. if (inputs.finalizingSeconds > kFinalizeFlushCeilingSeconds) return RecordPhase::Failed; return RecordPhase::Finalizing; } - // Terminal phases are sticky: once the verdict is in, a later tick (a stray - // extra call before the shell has finished tearing down) must not flip it. case RecordPhase::Done: case RecordPhase::Failed: default: diff --git a/src/core/capture/capture_realtime.h b/src/core/capture/capture_realtime.h index d497e8b..b26805b 100644 --- a/src/core/capture/capture_realtime.h +++ b/src/core/capture/capture_realtime.h @@ -1,27 +1,11 @@ #pragma once -// capture_realtime — the REAPER-free logic behind the realtime-record backend (M8). -// (Renamed from realtime_record in Q-W3 — the Q-9 naming rider: the PURE module -// takes the stem, the shell takes the suffix — capture_realtime_shell.cpp / -// capture_realtime_finalize.cpp — matching the drag_out ↔ drag_out_win model.) -// -// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO -// vendor/ includes. Standard library only. The realtime shell drives the -// transport, the temp track, the send routing, and the file move — -// all REAPER-bound and DAW-verified. The genuinely pure, easy-to-get-wrong -// pieces are split out here and unit-tested outside the DAW: -// -// 1. the record-mode/recipe bookkeeping: given a capture scope + a desired -// FX-tap point (post-fader / pre-FX / post-FX-pre-fader), the I_RECMODE and -// I_RECMODE_FLAGS integer values the temp track must carry. -// 2. the recorded-file -> Sample mapping: given a finished capture (the -// recorded file's project-relative path + the request's own bounds/format), -// the populated Sample handed to bank_model. Mirrors the inline Sample -// population OfflineRenderBackend does — factored out so it is tested once, -// without a DAW, and shared shape with the offline path is guaranteed. -// -// The I_RECMODE / I_RECMODE_FLAGS bit MEANINGS are transcribed verbatim from -// reaper_plugin_functions.h line ~2197-2198 (see kRecMode* constants); the CHOICE -// of which values each scope needs is this module's logic and is tested. +// capture_realtime — the REAPER-free logic behind the realtime-record backend. +// The shell drives the transport, temp track, send routing, and file move; the +// pure pieces split out here and unit-tested outside the DAW are: (1) record- +// mode bookkeeping — scope + FX-tap point -> I_RECMODE/I_RECMODE_FLAGS values +// (bit MEANINGS transcribed verbatim from reaper_plugin_functions.h ~2197-2198; +// the CHOICE of value per scope is this module's tested logic) — and (2) the +// recorded-file -> Sample mapping (mirrors OfflineRenderBackend's population). #include #include @@ -35,26 +19,17 @@ using model::Sample; using model::Tier; using model::SourceMode; -// --- I_RECMODE values (verbatim from SDK header ~2197) ----------------------- -// -// I_RECMODE : int * : record mode, 0=input, 1=stereo out, 2=none, -// 3=stereo out w/latency compensation, 4=midi output, 5=mono out, -// 6=mono out w/ latency compensation, 7=midi overdub, 8=midi replace. -// -// We record a track's OUTPUT (the scoped signal routed into the temp track), -// latency-compensated, so the recorded file lines up sample-accurately with the -// source. Stereo vs mono is chosen by the request's channel count. +// I_RECMODE (verbatim from SDK header ~2197): 0=input, 1=stereo out, 2=none, +// 3=stereo out w/latency comp, 4=midi output, 5=mono out, 6=mono out w/latency +// comp, 7=midi overdub, 8=midi replace. We record a track's OUTPUT, latency- +// compensated, so the recorded file lines up sample-accurately with the source. inline constexpr int kRecModeStereoOutLatComp = 3; // stereo out w/latency comp inline constexpr int kRecModeMonoOutLatComp = 6; // mono out w/latency comp -// --- I_RECMODE_FLAGS values (verbatim from SDK header ~2198) ------------------ -// -// I_RECMODE_FLAGS : int * : record mode flags, &3=output recording mode -// (0=post fader, 1=pre-fx, 2=post-fx/pre-fader). -// -// This is the ONLY documented pre-FX tap in the whole SDK — offline render has no -// pre-FX bit (see render_settings.h note + the M10 null-test note in PLAN.md). -// The realtime backend is therefore the true pre-FX "dry" path. +// I_RECMODE_FLAGS (verbatim from SDK header ~2198): &3=output recording mode +// (0=post fader, 1=pre-fx, 2=post-fx/pre-fader). This is the only documented +// pre-FX tap in the SDK — offline render has no pre-FX bit — so the realtime +// backend is the true pre-FX "dry" path. inline constexpr int kRecOutPostFader = 0; // &3==0: post-fader (fully wet) inline constexpr int kRecOutPreFx = 1; // &3==1: pre-FX (true dry) inline constexpr int kRecOutPostFxPreFader = 2; // &3==2: post-FX, pre-fader @@ -68,41 +43,34 @@ enum class OutputTap { }; // The concrete record-mode values a temp track must carry to capture the scoped -// output. `recMode` sets I_RECMODE (stereo/mono, latency-compensated); `recModeFlags` -// sets the &3 output-recording tap bits (higher bits are left at their default 0 -// here — we only own the tap-point bits). +// output. `recMode` sets I_RECMODE (stereo/mono, latency-compensated); +// `recModeFlags` sets the &3 output-recording tap bits (we only own those bits). struct RecordModePlan { int recMode = kRecModeStereoOutLatComp; int recModeFlags = kRecOutPostFader; }; -// Maps (channelCount, tap) to the record-mode values. -// channelCount <= 1 -> mono-out latency-comp; otherwise stereo-out latency-comp. -// tap -> the &3 output-recording bits. -// Pure so the "which I_RECMODE for N channels + this tap" rule is unit-tested -// without a DAW; the shell reads the request and applies these via -// SetMediaTrackInfo_Value(I_RECMODE / I_RECMODE_FLAGS). +// Maps (channelCount, tap) to the record-mode values: channelCount <= 1 -> +// mono-out latency-comp, else stereo-out; tap -> the &3 bits. The shell applies +// these via SetMediaTrackInfo_Value(I_RECMODE / I_RECMODE_FLAGS). RecordModePlan recordModePlanFor(int channelCount, OutputTap tap); -// Maps a wetDry value to the output tap point. 1.0 (fully wet) -> PostFader; any -// value < 1.0 -> PreFx (true dry — the realtime backend's distinguishing -// capability). Kept pure + separate from recordModePlanFor so the wet/dry -> -// tap decision is tested on its own; PostFxPreFader is not selected by wetDry -// (it is an explicit future option, not on the wet/dry axis). +// Maps a wetDry value to the output tap point: 1.0 (fully wet) -> PostFader, +// anything less -> PreFx (true dry — the realtime backend's distinguishing +// capability over offline render). PostFxPreFader is not reachable from wetDry. OutputTap outputTapForWetDry(double wetDry); // --- Recorded-file -> Sample mapping ---------------------------------------- -// + // The inputs a finished realtime capture yields, gathered by the shell into a -// pure struct so the Sample population is a single tested transform (mirror of -// the inline population in OfflineRenderBackend::capture). +// pure struct so Sample population is a single tested transform (mirrors the +// inline population in OfflineRenderBackend::capture). struct RecordedCapture { - // Project-relative path of the recorded file (relative-paths-only invariant; - // the shell resolves REAPER's recorded absolute path back to project-relative). + // Project-relative path of the recorded file (the shell resolves REAPER's + // absolute path back to project-relative). std::string relativePath; - // The disambiguating tag that named the file (feeds the Sample id, so id and - // file name stay consistent — same discipline as the offline path). + // The disambiguating tag that named the file (feeds the Sample id). std::string uniqueTag; // Echoed from the request (exact bounds — no re-measuring the file). @@ -115,60 +83,41 @@ struct RecordedCapture { int channelCount = 0; - // TEST-ONLY / dead in production (Q-W3 review follow-up): the shell no longer - // populates these five fields before calling sampleFromRecordedCapture — the - // finalize path (capture_realtime_finalize.cpp) leaves them at their defaults - // and instead calls the shared stampCaptureSample(result.sample, ...) right - // after, which writes Sample::sampleRate/captureTempo/captureTimeSigNum/ - // captureTimeSigDenom/createdTimestamp directly, overwriting whatever - // sampleFromRecordedCapture set from these. Kept (not deleted) because the pure - // unit tests still construct/assert them directly; removing the fields is a - // struct-shape decision out of scope here. + // Left at defaults here — capture_realtime_finalize.cpp calls + // stampCaptureSample(result.sample, ...) afterward, overwriting these five + // from the live project. Kept because the pure unit tests still assert them. int sampleRate = 0; // 0 when the project rate was unknown (as offline) - double captureTempo = 0.0; // BPM at capture time (shell reads Master_GetTempo) - // Time signature at capture start (L7 F1; shell reads TimeMap_GetTimeSigAtTime). - // 0/0 = unstamped (matches the Sample default; formatter renders a blank read-out). - int captureTimeSigNum = 0; + double captureTempo = 0.0; // BPM at capture time + int captureTimeSigNum = 0; // 0/0 = unstamped int captureTimeSigDenom = 0; - std::int64_t createdTimestamp = 0; // unix epoch seconds (shell reads the clock) + std::int64_t createdTimestamp = 0; // unix epoch seconds }; -// Builds the Sample for a finished realtime capture. Deliberately identical in -// shape to OfflineRenderBackend's population: exact request bounds (no rounding), -// scratch tier, empty content hash (does not dedup), lengthSeconds = end - start. -// PPQ/beats are left 0 (a musical-placement concern deferred exactly as offline). +// Builds the Sample for a finished realtime capture: exact request bounds, +// scratch tier, empty content hash, lengthSeconds = end - start. PPQ/beats +// left 0 (deferred, as offline). Sample sampleFromRecordedCapture(const RecordedCapture& cap); -// --- Async record-phase state machine (M8 rework) ---------------------------- +// --- Async record-phase state machine ---------------------------------------- // -// A realtime record spans many timer ticks (CSurf_OnRecord starts the transport on -// REAPER's audio thread and returns immediately — it does NOT block until the range -// completes). The completion decision — "given where the transport is now, should -// the tick keep waiting, stop-and-flush, finalize, or give up?" — is pure and -// exactly the kind of off-by-one/edge logic a unit test locks without a DAW. It is -// factored out here; the REAPER shell only reads the transport/clock/file and applies -// the verdict (stop, wait for the file to flush, then finalize/abort + restore). +// A realtime record spans many timer ticks (CSurf_OnRecord starts the transport +// on REAPER's audio thread and returns immediately — it does not block until the +// range completes). The completion decision — keep waiting, stop-and-flush, +// finalize, or give up — is pure and unit-tested without a DAW; the shell only +// reads the transport/clock/file and applies the verdict. // -// The lifecycle has TWO waits, not one: -// 1. the RECORD wait (Recording): the transport is running; we wait for the play -// cursor to reach the range end — OR the user stops early — OR a wall-clock -// safety ceiling trips (a started-but-never-advancing transport, §3 of review). -// 2. the FLUSH wait (Finalizing): the transport is stopped but REAPER closes/flushes -// the recorded take on the AUDIO thread — the file may not be fully written/closed -// for a tick or two. We defer the file move until the file exists AND is stable -// (§2 of review), bounded by a flush ceiling so a file that never appears fails -// cleanly rather than hanging. +// Two waits, not one: +// 1. RECORD wait (Recording): transport running; wait for the play cursor to +// reach the range end, OR the user stops early, OR a wall-clock safety +// ceiling trips (a started-but-never-advancing transport). +// 2. FLUSH wait (Finalizing): transport stopped but REAPER closes/flushes the +// recorded take on the audio thread — the file may lag a tick or two. +// Defer the move until the file exists AND is stable, bounded by a flush +// ceiling so a file that never appears fails cleanly instead of hanging. -// Where an in-progress capture is in its lifecycle. -// Recording — live: transport running, shell keeps ticking. -// Finalizing — live-but-stopped: transport halted, shell stops the transport once -// then ticks waiting for the recorded file to flush/stabilize. -// Done — terminal: the file is flushed + stable, finalize (move + Sample) now. -// Failed — terminal: the flush ceiling tripped without a stable file — give up -// (RenderFailed) + restore. (A record that produced NO file at all also -// lands here via the shell's finalize returning RenderFailed.) -// Only Recording and Finalizing are live phases the shell advances per tick; Done and -// Failed are the shell's verdict to act on (finalize-or-fail, then restore). +// Where an in-progress capture is in its lifecycle: Recording (live, transport +// running) and Finalizing (live-but-stopped, waiting for flush) are the two +// waits above; Done/Failed are terminal — the shell's verdict to act on. enum class RecordPhase { Recording, Finalizing, @@ -176,63 +125,34 @@ enum class RecordPhase { Failed }; -// A distilled transport reading for the pure transition, so the state machine never -// touches a REAPER type. `recording` is (GetPlayStateEx & 4) != 0; `playPosition` -// is GetPlayPositionEx (latency-compensated what-you-hear position). +// A distilled transport reading so the state machine never touches a REAPER +// type. `recording` is (GetPlayStateEx & 4) != 0; `playPosition` is +// GetPlayPositionEx (latency-compensated). struct TransportReading { bool recording = false; double playPosition = 0.0; }; -// Everything the pure transition needs beyond the current phase, gathered by the -// shell each tick so the machine stays REAPER-free AND owns every timing/ceiling -// decision (the shell only reads and reports; it never decides a transition itself). +// Everything the pure transition needs beyond the current phase, gathered by +// the shell each tick (the shell only reads and reports; never decides). struct RecordTickInputs { TransportReading transport; - - // Wall-clock seconds since begin() (the shell reads a steady clock). Drives the - // record safety ceiling: a transport that starts but never advances to the range - // end (stuck / looping) would otherwise keep the machine in Recording forever. - double elapsedSeconds = 0.0; - - // Wall-clock seconds spent in the Finalizing phase (since the transport stop). - // Drives the flush ceiling: bound the deferred-finalize wait so a file that never - // stabilizes fails cleanly instead of hanging. - double finalizingSeconds = 0.0; - - // Whether the recorded take's file exists AND is stable/closed this tick (the - // shell resolves the take source path and checks size-stable-across-a-tick). - // Only consulted in Finalizing. - bool fileReady = false; + double elapsedSeconds = 0.0; // wall-clock since begin() — record ceiling + double finalizingSeconds = 0.0; // wall-clock in Finalizing — flush ceiling + bool fileReady = false; // recorded file exists+stable (Finalizing only) }; -// --- Safety ceilings (named constants, review §2/§3) ------------------------- -// -// kRecordMarginSeconds: added to the record's nominal duration (end - start) to form -// the record wall-clock ceiling. Generous so a normal record (with pre-roll, count-in, -// or transport latency) never trips it; tight enough that a stuck transport is force- -// terminated within a few seconds of overrun. +// Record ceiling margin added to nominal duration: generous enough that +// pre-roll/count-in/latency never trips it, tight enough a stuck transport is +// force-terminated within seconds. inline constexpr double kRecordMarginSeconds = 5.0; -// kFinalizeFlushCeilingSeconds: the max wall-clock the Finalizing phase waits for the -// recorded file to flush/stabilize before giving up (RenderFailed). REAPER closes the -// take on the audio thread within a tick or two in practice; this is a generous bound. +// Max wall-clock Finalizing waits for the file to flush/stabilize before +// giving up (REAPER closes the take within a tick or two in practice). inline constexpr double kFinalizeFlushCeilingSeconds = 5.0; -// The pure transition: given the current phase, this tick's inputs, and the record -// range end, return the next phase. Total + deterministic. -// -// From Recording: -// * recording AND cursor < end AND under the record ceiling -> Recording (wait) -// * recording AND cursor >= end -> Finalizing (reached end) -// * NOT recording -> Finalizing (stopped early) -// * recording BUT over the record ceiling (end-start+margin)-> Finalizing (stuck: forced) -// From Finalizing: -// * fileReady -> Done (flushed + stable) -// * over the flush ceiling without a stable file -> Failed (give up) -// * otherwise -> Finalizing (keep flushing) -// Done and Failed are sticky: feeding a terminal phase back returns it unchanged, so a -// late tick before teardown finishes cannot flip the verdict (the idempotence the +// The pure transition (total + deterministic). Done/Failed are sticky — a late +// tick before teardown finishes cannot flip the verdict (the idempotence the // shell's single-restore relies on). RecordPhase advanceRecordPhase(RecordPhase current, const RecordTickInputs& inputs, diff --git a/src/core/capture/insert_plan.cpp b/src/core/capture/insert_plan.cpp index b58d56c..ccc73ea 100644 --- a/src/core/capture/insert_plan.cpp +++ b/src/core/capture/insert_plan.cpp @@ -7,14 +7,14 @@ namespace reasampler::capture { namespace { // Base target bits (mode&3). We use only 0 (current track) and 1 (new track). -constexpr int kBaseCurrentTrack = 0; // add to current track -constexpr int kBaseNewTrack = 1; // add new track +constexpr int kBaseCurrentTrack = 0; +constexpr int kBaseNewTrack = 1; // Tempo-conform bits, verbatim from the header doc-comment. -constexpr int kMatchTempo1x = 8; // &8: try to match tempo 1x -constexpr int kMatchTempoHalf = 16; // &16: try to match tempo 0.5x -constexpr int kMatchTempoDbl = 32; // &32: try to match tempo 2x -constexpr int kDontPreservePitch = 64; // &64: don't preserve pitch when matching tempo +constexpr int kMatchTempo1x = 8; +constexpr int kMatchTempoHalf = 16; +constexpr int kMatchTempoDbl = 32; +constexpr int kDontPreservePitch = 64; } // namespace @@ -24,8 +24,7 @@ int computeInsertMode(const InsertOptions& opts) { switch (opts.conform) { case TempoConform::None: - // No tempo bits: native length, no stretch. (Also never &4.) - return mode; + return mode; // native length, no stretch; never &4 case TempoConform::Ratio1x: mode |= kMatchTempo1x; break; @@ -37,9 +36,7 @@ int computeInsertMode(const InsertOptions& opts) { break; } - // Tempo bits are set (conform != None). Add the pitch-shift bit only when the - // caller asked NOT to preserve pitch. When conform == None we already returned - // above, so this can never fire without a tempo bit present. + // Reached only when a tempo bit is set (None already returned above). if (!opts.preservePitch) mode |= kDontPreservePitch; diff --git a/src/core/capture/insert_plan.h b/src/core/capture/insert_plan.h index 2bb45e9..e5ec5b0 100644 --- a/src/core/capture/insert_plan.h +++ b/src/core/capture/insert_plan.h @@ -1,34 +1,32 @@ #pragma once -// insert_plan — the REAPER-free logic behind the `insert` shell (M6): computing -// the InsertMedia `mode` bitmask from a small options struct. +// insert_plan — the REAPER-free logic behind the `insert` shell: computing the +// InsertMedia `mode` bitmask from a small options struct. // -// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO -// vendor/ includes. Standard library only. The one genuinely testable-outside-DAW -// piece of insert is the mode-bit arithmetic — the InsertMedia bitfield is easy to -// get wrong and its bits are load-bearing for the "no silent time-stretch" -// invariant, so it is factored here and unit-tested. The REAPER-bound placement -// (InsertMedia call, edit-cursor movement, undo block) lives in insert.cpp and is -// DAW-verified. +// PURE MODULE: NO REAPER types, NO SWELL, NO vendor/ includes. Standard library +// only. The InsertMedia bitfield is easy to get wrong and its bits are +// load-bearing for the "no silent time-stretch" invariant, so it's factored here +// and unit-tested. The REAPER-bound placement (InsertMedia call, edit-cursor +// movement, undo block) lives in insert.cpp and is DAW-verified. // -// The bit meanings below are transcribed VERBATIM from the authoritative header +// Bit meanings below are transcribed VERBATIM from the authoritative header // doc-comment (vendor/reaper-sdk/sdk/reaper_plugin_functions.h, InsertMedia): // mode: 0=add to current track, 1=add new track, 3=add to selected items as // takes, &4=stretch/loop to fit time sel, &8=try to match tempo 1x, // &16=try to match tempo 0.5x, &32=try to match tempo 2x, // &64=don't preserve pitch when matching tempo, ... -// We intentionally use only the base target (0/1) and the tempo-conform bits -// (&8/&16/&32/&64). We NEVER set &4 (stretch/loop to fit time selection) — that is -// the silent-time-stretch path the tool forbids (CONTEXT.md §Non-goals). +// We use only the base target (0/1) and the tempo-conform bits (&8/&16/&32/&64). +// We NEVER set &4 (stretch/loop to fit time selection) — the silent-time-stretch +// path the tool forbids. #include namespace reasampler::capture { // Where InsertMedia drops the item. Maps to the low bits of `mode` (mode&3). -// We expose only the two placement targets M6 needs; "add as takes" (3) is a -// later concern (YAGNI). Both insert AT THE EDIT CURSOR — that is REAPER's -// convention for base modes 0/1 (the header names no explicit edit-cursor bit; -// see the flagged runtime assumption in insert.cpp). +// We expose only the two placement targets needed here; "add as takes" (3) is +// out of scope. Both insert at the edit cursor — REAPER's convention for base +// modes 0/1 (the header names no explicit edit-cursor bit; see the flagged +// runtime assumption in insert.cpp). enum class InsertTarget { NewTrack, // mode base 1: add a new track for the item CurrentTrack, // mode base 0: add to the current/selected track diff --git a/src/core/capture/render_settings.cpp b/src/core/capture/render_settings.cpp index a83c5ca..1effb01 100644 --- a/src/core/capture/render_settings.cpp +++ b/src/core/capture/render_settings.cpp @@ -10,9 +10,7 @@ namespace reasampler::capture { double autoTrimEndRatio() { - // Amplitude ratio = 10^(dB/20). Derived from kAutoTrimThresholdDb so the dB is - // the single source of truth (header ~3062: RENDER_TRIMEND is an amplitude ratio, - // "0.5 means -6.02 dB"). For -72 dB this is ~= 0.00025119. + // Amplitude ratio = 10^(dB/20) (header ~3062). For -72 dB this is ~0.00025119. return std::pow(10.0, kAutoTrimThresholdDb / 20.0); } @@ -20,8 +18,7 @@ TailRenderSettings tailRenderSettingsFor(TailMode mode, double manualTailMs) { TailRenderSettings t; switch (mode) { case TailMode::None: - // Exact bounds — byte-identical to the pre-tail no-tail capture. Tail off, - // disable-all normalize (the current default), no trim. + // Exact bounds — byte-identical to the pre-tail capture. t.tailFlag = kTailFlagNone; t.tailMs = 0.0; t.normalize = kNormalizeDisableAll; @@ -29,12 +26,10 @@ TailRenderSettings tailRenderSettingsFor(TailMode mode, double manualTailMs) { return t; case TailMode::Auto: - // Generous 8 s tail, then SURGICAL normalize: ONLY the trim-ending-silence - // bit (32768) — every other postprocessing bit clear. A fixed-threshold - // trailing-silence trim is a pure boundary decision (it scales/limits/fades - // nothing), so it re-introduces none of the coloring the disable-all bit - // guarded against, and two identical requests trim at the identical sample - // -> bit-identical repeats hold (spec §surgical normalize). + // Surgical normalize: only the trim-ending-silence bit set, every other + // postprocessing bit clear. A fixed-threshold trim scales/limits/fades + // nothing, so identical requests trim at the identical sample -> holds + // the bit-identical-repeats invariant. t.tailFlag = kTailFlagCustomBounds; t.tailMs = kMaxTailMs; t.normalize = kNormalizeTrimEnd; @@ -42,10 +37,7 @@ TailRenderSettings tailRenderSettingsFor(TailMode mode, double manualTailMs) { return t; case TailMode::Manual: - // Fixed tail, no trim -> keep the disable-all normalize exactly as the - // no-tail path does. Clamp to the 8 s cap even here: the runaway guard - // applies whether the length came from the Auto default or an explicit - // request (spec §Manual override). Negative requests floor to 0. + // Clamped to the cap regardless of source; negative floors to 0. t.tailFlag = kTailFlagCustomBounds; t.tailMs = std::clamp(manualTailMs, 0.0, kMaxTailMs); t.normalize = kNormalizeDisableAll; @@ -60,14 +52,10 @@ 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; + return rangeEndSeconds; // exact, no extra recording case TailMode::Auto: - // The 8 s runaway cap past the range end; the decay-trim shortens it later. - return rangeEndSeconds + kMaxTailSeconds; + return rangeEndSeconds + kMaxTailSeconds; // runaway cap; decay-trim shortens later 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). @@ -75,42 +63,36 @@ double realtimeRecordWindowEnd(TailMode mode, double 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 - // handled by fxBypassPlanFor, not by these render bits. + // wetDry doesn't affect this mapping (seam for future dry work); FX scoping + // is handled by fxBypassPlanFor, not by these render bits. RenderSettingsChoice c; switch (mode) { case SourceMode::MasterMix: case SourceMode::TimeSelection: - // Master IS the mix — wet-only; &(1|2)==0, no source bits. - c.settings = kRenderMasterMix; + c.settings = kRenderMasterMix; // wet-only, no source bits c.supported = true; return c; case SourceMode::SelectedTracks: - // Selected tracks via master (&128) — wet (post-FX). Header ~3041. c.settings = kRenderSelTracksViaMaster; c.supported = true; return c; case SourceMode::SelectedItems: - // Selected media items, rendered to ONE file (single-file bit) so a - // multi-item selection yields a single bank entry, not N wavs. + // Single-file bit so a multi-item selection yields one bank entry. c.settings = kRenderSelItems | kRenderSingleFile; c.supported = true; return c; case SourceMode::RazorArea: - // Render razor edits to ONE file (same single-file rationale as items). c.settings = kRenderRazorEdits | kRenderSingleFile; c.supported = true; return c; case SourceMode::Realtime: - // Not an offline-render source — the realtime backend (M8) owns it. c.settings = kRenderMasterMix; - c.supported = false; + c.supported = false; // not an offline-render source return c; } // Unreachable for a valid enum; fail closed (unsupported) rather than render. @@ -135,17 +117,13 @@ FxBypassPlan fxBypassPlanFor(CaptureScope scope) { FxBypassPlan p; switch (scope) { case CaptureScope::Item: - // Item = take/item FX ONLY. Bypass the item's own track FX, every - // ancestor's FX, and the master's FX. (Take FX live in the item and - // are always rendered — there is no track to bypass them from.) + // Take FX live in the item and are always rendered — bypass everything else. p.bypassSelfFx = true; p.bypassAncestorFx = true; p.bypassMaster = true; return p; case CaptureScope::Track: - // Track = item FX + the selected track's OWN FX. Keep self FX; bypass - // every ancestor (parent/folder) and the master. Parent/master GAIN - // still applies (I_FXEN is FX-only) — documented boundary. + // Keep self FX; bypass every ancestor (parent/folder) and the master. p.bypassSelfFx = false; p.bypassAncestorFx = true; p.bypassMaster = true; @@ -158,24 +136,19 @@ std::vector parseRazorEdits(const std::string& razorString) { std::vector ranges; std::istringstream in(razorString); - // The string is space-separated TRIPLES: . - // A track-audio area's third token is the literal two-char string `""`; an - // envelope-lane area's is a GUID `{…}`. We keep only track-audio triples. std::string startTok, endTok, guidTok; while (in >> startTok >> endTok >> guidTok) { - // Envelope-lane areas carry a real GUID; skip them (razor captures track audio only). - // A track-audio area's GUID token is the empty quoted string `""`. + // Skip envelope-lane areas (real GUID); keep only track-audio (`""`). if (guidTok != "\"\"") continue; - // Parse the two time tokens. std::stod throws on garbage — guard so one - // malformed triple does not abort the whole parse. + // std::stod throws on garbage — guard so one malformed triple doesn't + // abort the whole parse. double start = 0.0, end = 0.0; try { std::size_t sp = 0, ep = 0; start = std::stod(startTok, &sp); end = std::stod(endTok, &ep); - // Reject tokens with trailing garbage (e.g. "1.0x") — a partial parse - // is a malformed area, not a valid range. + // Reject trailing garbage (e.g. "1.0x") — a partial parse is malformed. if (sp != startTok.size() || ep != endTok.size()) continue; } catch (...) { continue; @@ -197,23 +170,13 @@ RazorRange razorUnionBounds(const std::vector& ranges) { } const std::vector& captureActionTable() { - // Built once (function-local static): two SCOPE actions, item + track. Both - // exact bounds by default; the tail mode a capture applies is read from the - // docked-panel setting at fire time (tail_control + bank_panel), so tail is NOT - // a per-action variant. Ids are FOREVER-STABLE — never edit a shipped string. - // Each action infers its range (razor-else-time) at fire time and enforces its - // FX-scope invariant via fxBypassPlanFor. The M7 CAPTURE_TRACKS_WET / - // CAPTURE_ITEMS_WET / CAPTURE_RAZOR_WET ids are RETIRED (mirror-unregistered in - // main.cpp); the CAPTURE_MASTER scope action is REMOVED (its id is likewise - // mirror-unregistered) — to capture the master you render a track. + // FOREVER-STABLE ids — never edit a shipped string. No master capture + // action (its id was retired; do not reintroduce it). static const std::vector table = { - // Item scope — item/take FX only. Suffix + phrase are channel-agnostic; the shell - // composes the FOREVER-STABLE id (prefix + "CAPTURE_ITEM") and the display name. {"CAPTURE_ITEM", "capture selected item(s)", "item", CaptureScope::Item}, - // Track scope — item FX + the track's own FX. {"CAPTURE_TRACK", "capture selected track(s)", "track", CaptureScope::Track}, diff --git a/src/core/capture/render_settings.h b/src/core/capture/render_settings.h index 4c88323..a80e9eb 100644 --- a/src/core/capture/render_settings.h +++ b/src/core/capture/render_settings.h @@ -1,26 +1,9 @@ #pragma once -// render_settings — the REAPER-free logic behind the capture action family. -// -// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO -// vendor/ includes. Standard library only. The capture shell (capture.cpp) and -// action layer (main.cpp) read the actual DAW state (time selection, selected -// tracks/items, razor strings, the ancestor-track chain) and hand the raw values -// here so the genuinely-pure, easy-to-get-wrong pieces are unit-tested outside -// the DAW: -// -// 1. sourceMode -> the RENDER_SETTINGS integer bit value (wet only). -// 2. a P_RAZOREDITS string -> the list of (start,end) ranges + their union bound. -// 3. range inference: razor-present -> razor union, else time selection. Range -// is a SOURCE choice orthogonal to the capture scope. -// 4. the FX-scope bypass plan: given a scope + an ancestor-chain length, which -// tracks' FX to bypass so each scope hears only the FX it should (the M7 -// "items captured through parent FX" defect is corrected here). -// 5. the capture-action table (id string, description, scope) — the taxonomy, -// in one place so main.cpp iterates it instead of hand-listing. -// -// The RENDER_SETTINGS bit MEANINGS are transcribed verbatim from -// reaper_plugin_functions.h line ~3041 (see kRender* constants); the CHOICE of -// which bits each source mode sets is this module's logic and is tested. +// render_settings — the REAPER-free logic behind the capture action family: +// sourceMode -> RENDER_SETTINGS bits, P_RAZOREDITS parsing + range union, +// razor-else-time inference, the FX-scope bypass plan, and the capture-action +// table main.cpp iterates. Bit MEANINGS below are transcribed verbatim from +// reaper_plugin_functions.h; the CHOICE of which bits each mode sets is tested. #include #include @@ -32,67 +15,44 @@ namespace reasampler::capture { using model::SourceMode; // --- RENDER_SETTINGS source/processing bits (verbatim from SDK header ~3041) -- -// -// Only the bits this module actually uses are named. Values are the documented bit -// weights; the DOC of each is the SDK header's, not a guess. inline constexpr int kRenderMasterMix = 0; // (&(1|2))==0, no source bits inline constexpr int kRenderSelItems = 32; // &32 selected media items inline constexpr int kRenderSelItemsViaMaster = 64; // &64 selected media items via master inline constexpr int kRenderSelTracksViaMaster = 128; // &128 selected tracks via master inline constexpr int kRenderRazorEdits = 4096; // &4096 render razor edits -// NOTE: kRenderPreFaderStems (&8192) is NOT used. REAPER offline render has no -// true pre-FX "dry" bit. FX scoping is done by the FX-bypass-around-render -// mechanism (see fxBypassPlan below) — bypassing the FX-enable of the tracks that -// fall outside a scope — NOT by any render bit. All capture actions render wet -// (post the FX that remain enabled); the scope decides which FX remain enabled. +// kRenderPreFaderStems (&8192) is deliberately NOT used — REAPER offline render +// has no true pre-FX "dry" bit. FX scoping is done by the FX-bypass-around-render +// mechanism (see fxBypassPlan below), not by any render bit. All capture actions +// render wet; the scope decides which FX remain enabled. inline constexpr int kRenderSingleFile = (4 << 16); // items/razor -> one file // --- Tail: RENDER_NORMALIZE / RENDER_TRIMEND bits + named constants ---------- // -// The capture-tail feature (docs/product/capture-tail.md) preserves reverb/release -// decay past the range end. Every offline capture renders custom-time-bounds, so -// the only tail-flag bit that ever applies is &1 (RENDER_TAILFLAG, header ~3047). -// These values are the pure part — mode -> (RENDER_* values) — unit-tested outside -// the DAW exactly like renderSettingsFor; the backend just applies them. -// -// RENDER_NORMALIZE bit meanings (verbatim from SDK header ~3051): -// &32768 = trim ending silence (the surgical Auto path) -// &(4<<16) = disable all render postprocessing (the None/Manual path) +// Every offline capture renders custom-time-bounds, so &1 (RENDER_TAILFLAG, +// header ~3047) is the only tail-flag bit that ever applies. RENDER_NORMALIZE +// (verbatim, header ~3051): &32768 = trim ending silence (Auto path); +// &(4<<16) = disable all render postprocessing (None/Manual path). inline constexpr int kNormalizeTrimEnd = 32768; // &32768 trim ending silence inline constexpr int kNormalizeDisableAll = (4 << 16); // &(4<<16) = 262144, disable all -// RENDER_TAILFLAG &1 = apply tail for custom time bounds (header ~3047). We render -// custom bounds unconditionally, so this is the only tail bit that ever applies. inline constexpr int kTailFlagNone = 0; -inline constexpr int kTailFlagCustomBounds = 1; // &1 +inline constexpr int kTailFlagCustomBounds = 1; // &1, header ~3047 -// Auto-trim trailing-silence threshold. -72 dB is quiet enough that the trimmed -// region is inaudible decay, loud enough to not chase a reverb's infinite noise -// floor. Daniel-set. Single source of truth: the RENDER_TRIMEND ratio derives from -// this dB, never the reverse. +// Auto-trim trailing-silence threshold; single source of truth (RENDER_TRIMEND +// ratio derives from this dB, never the reverse). Daniel-set. inline constexpr double kAutoTrimThresholdDb = -72.0; -// Max tail rendered past the range end. The runaway guard: a non-decaying or -// looping signal never crosses the trim threshold, so this caps the render. -// Daniel-set. Shared by the offline (T1) and future realtime (T2) tail paths. +// Runaway guard: max tail rendered past the range end, so a non-decaying or +// looping signal doesn't render forever. Daniel-set; shared by offline+realtime. inline constexpr double kMaxTailSeconds = 8.0; inline constexpr double kMaxTailMs = 8000.0; -// Derived linear amplitude ratio for RENDER_TRIMEND. The header (~3062) documents -// RENDER_TRIMEND as an amplitude ratio ("0.5 means -6.02 dB"), i.e. 10^(dB/20). -// Derived from kAutoTrimThresholdDb so the dB stays the single source of truth and -// a future config change to the dB does not require hand-recomputing the ratio. -// -// std::pow is not constexpr before C++26, so this is a function, not a constant. -// For -72 dB: 10^(-72/20) = 10^(-3.6) ~= 0.00025119 (the value the DAW confirm targets). +// Derived linear amplitude ratio for RENDER_TRIMEND (header ~3062: an amplitude +// ratio, "0.5 means -6.02 dB", i.e. 10^(dB/20)) from kAutoTrimThresholdDb. +// Function not constant: std::pow isn't constexpr before C++26. double autoTrimEndRatio(); -// The three tail states (docs/product/capture-tail.md §The three tail states): -// None — exact bounds, no tail. Byte-identical to the pre-tail capture. The -// default and the ONLY mode for null-test / verify captures. -// Auto — generous 8 s tail then trim trailing silence to -72 dB (surgical -// normalize). The user-facing tail-on option (panel toggle). -// Manual — a fixed tail length (clamped to the 8 s cap), no trim. +// The three tail states — see src/core/capture/CLAUDE.md. enum class TailMode { None, Auto, @@ -100,9 +60,9 @@ enum class TailMode { }; // The RENDER_* values a tail mode drives, in addition to the exact STARTPOS/ENDPOS -// the backend already sets. `trimEnd` is meaningful only when the trim-end normalize -// bit is set (Auto); it is 0 otherwise. This is the pure mapping — the backend reads -// these four fields straight onto GetSetProjectInfo. +// the backend already sets. `trimEnd` is meaningful only when the trim-end +// normalize bit is set (Auto). The backend reads these straight onto +// GetSetProjectInfo. struct TailRenderSettings { int tailFlag = kTailFlagNone; // RENDER_TAILFLAG (0 or &1) double tailMs = 0.0; // RENDER_TAILMS @@ -110,54 +70,36 @@ struct TailRenderSettings { double trimEnd = 0.0; // RENDER_TRIMEND (only used when trim bit set) }; -// Maps a tail mode (+ the requested manual tail ms) to its RENDER_* values. -// `manualTailMs` is used ONLY for TailMode::Manual (ignored otherwise). Manual is -// clamped to kMaxTailMs — the runaway guard applies whether the length came from -// the Auto default or an explicit request (spec §Manual override). Pure + tested. +// Maps a tail mode (+ requested manual tail ms, used only for Manual) to its +// RENDER_* values. Manual is clamped to kMaxTailMs regardless of source. 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). +// The realtime record-window end (project seconds): realtime does NOT drive +// RENDER_*, it records a generous window and trims later, so this is where the +// transport actually stops. None -> exact rangeEndSeconds; Auto -> +8s runaway +// cap; Manual -> + clamp(manualTailMs, kMaxTailMs)/1000. 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). +// for SourceMode::Realtime (that backend doesn't use offline render). struct RenderSettingsChoice { int settings = kRenderMasterMix; bool supported = true; // false => not an offline-render source (e.g. Realtime) }; // Maps a source mode to its RENDER_SETTINGS value (which content the render -// covers). FX scoping is orthogonal — done by fxBypassPlan, not by these bits. -// `wetDry` is accepted but ignored for the mapping — retained in CaptureRequest -// as the seam for future dry work (M10 null test). -// -// CONFIRMED (SDK header ~3041): -// MasterMix / TimeSelection -> master mix (0). -// SelectedTracks -> &128 selected tracks via master. -// SelectedItems -> &32 | single-file (one wav, not one-per-item). -// RazorArea -> &4096| single-file. +// covers); FX scoping is orthogonal (done by fxBypassPlan). `wetDry` is +// accepted but ignored — retained as the seam for future dry work. CONFIRMED +// (SDK header ~3041): MasterMix/TimeSelection -> 0; SelectedTracks -> &128; +// SelectedItems -> &32|single-file; RazorArea -> &4096|single-file. RenderSettingsChoice renderSettingsFor(SourceMode mode, double wetDry); -// --- Capture scope: the FX-scope invariant (Daniel, critical) ---------------- +// --- Capture scope: the FX-scope invariant ------------------------------------ // -// Two FX scopes. The render RANGE (razor-else-time) is orthogonal to the scope. -// Item -> item/take FX ONLY (no track, no parent/folder, no master FX). -// Track -> item FX + the selected track's OWN track FX (no parent/folder/master). -// There is NO master scope: to capture the master you render a track instead. The -// master track's FX/gain/pan are still NEUTRALIZED as part of the out-of-scope -// chain for both item and track captures (bypassMaster below) — master is a -// bypass target, not a capture scope. +// See src/core/capture/CLAUDE.md for the scope contract. There is NO master +// scope; the master track's FX/gain/pan are still NEUTRALIZED as part of the +// out-of-scope chain (bypassMaster below) — master is a bypass target only. enum class CaptureScope { Item, Track, @@ -169,34 +111,26 @@ SourceMode sourceModeForScope(CaptureScope scope); // --- Range inference: razor-else-time (orthogonal to scope) ------------------- // -// Every scope action infers its render range the same way: if a razor area is -// present, use the razor union; otherwise use the time selection. Razor is a -// range SOURCE, not a capture mode (the M7 four-mode model conflated them). +// Razor-present -> razor union; otherwise time selection. Razor is a range +// source, not a capture mode. enum class RangeSource { Razor, // a razor area is present -> use its union bound TimeSelection, // no razor -> use the time selection }; -// Picks the range source. Pure so the "razor wins when present" rule is tested -// without a DAW; the shell supplies whether any razor area was found. +// Picks the range source. Pure so "razor wins when present" is tested without +// a DAW; the shell supplies whether any razor area was found. RangeSource inferRangeSource(bool hasRazorArea); // --- FX-bypass plan: which tracks' FX to bypass for a scope ------------------- // -// Given a CaptureScope, returns three boolean flags: whether to bypass (a) the -// captured track's OWN FX, (b) each of its ancestor (parent/folder) tracks' FX, -// and (c) the master FX. The caller (FxBypassGuard) resolves these flags to -// concrete MediaTrack* by walking the ancestor chain via GetParentTrack and -// clears I_FXEN on each flagged track, snapshotting first (RAII restore). -// -// SCOPE BOUNDARY: I_FXEN bypasses a track's FX plugins but NOT its volume/pan. -// The guard (FxBypassGuard, main.cpp) therefore ALSO neutralizes the fader GAIN -// (D_VOL -> unity) of every track in this same bypass set, so a Track/Item -// capture rendered via master does NOT bake in the parent/folder/master fader -// level (Daniel: the capture is likely re-routed through that chain later). PAN -// is deliberately left untouched (D_PAN is coupled to D_WIDTH/D_PANLAW — a clean -// neutralize is non-trivial; flagged as a follow-up, not half-done). This plan -// selects the SET; the guard applies both the FX bypass and the gain neutralize. +// Given a CaptureScope, returns three boolean flags: bypass (a) the captured +// track's OWN FX, (b) every ancestor (parent/folder) track's FX, (c) the +// master FX. The caller (FxBypassGuard, shell) walks the ancestor chain via +// GetParentTrack, clears I_FXEN on each flagged track (RAII restore), and also +// neutralizes D_VOL/D_PAN/D_WIDTH/D_PANLAW to unity/center on the same set — +// I_FXEN alone doesn't touch a track's volume/pan. This plan selects the set; +// the guard applies both the FX bypass and the neutralize. struct FxBypassPlan { bool bypassSelfFx = false; // the captured track's own FX bool bypassAncestorFx = false; // every ancestor (parent/folder) track's FX @@ -213,38 +147,24 @@ struct RazorRange { }; // Parses ONE track's P_RAZOREDITS string (SDK header ~2899): space-separated -// TRIPLES of . The envelope GUID is "" (an empty -// quoted string, i.e. the literal two chars `""`) for a track-audio area and a -// GUID like {…} for an envelope-lane area. -// -// Returns only the track-audio ranges (envelope-lane triples are skipped — razor -// captures target track audio, not envelope lanes). Malformed/short trailing tokens are ignored, not fatal. -// A range with end <= start is dropped (no negative/empty areas leak through). +// TRIPLES of , envGuid == `""` for a track-audio +// area vs a GUID for an envelope-lane area. Returns only track-audio ranges +// (envelope-lane triples skipped); malformed trailing tokens are ignored, not +// fatal; a range with end <= start is dropped. std::vector parseRazorEdits(const std::string& razorString); // The union bound (min start, max end) of a set of razor ranges — the exact -// window the offline render must cover so every area is inside the rendered file. -// Returns {0,0} for an empty input (caller treats that as "no razor area"). +// window the offline render must cover. {0,0} for empty input ("no razor area"). RazorRange razorUnionBounds(const std::vector& ranges); // --- Capture-action taxonomy (the bindable set main.cpp registers) ----------- // -// One row per bindable SCOPE action: item and track. The range each captures -// (razor-else-time) is inferred at fire time, not a mode. TAIL is NOT a per-action -// variant — the tail MODE (None/Auto/Manual) is a panel SETTING the capture reads -// at fire time (see tail_control + bank_panel), so a single pair of actions covers -// every tail state. Bounded, discoverable, NO dialogs (the tool's no-clutter ethos). +// One row per bindable scope action (item/track); range inference and tail +// mode are read at fire time, not baked into the row. The row stores only the +// channel-agnostic command-id SUFFIX + description PHRASE; the registering +// shell composes the full channel-qualified id/name via app_version. // -// Phase V (V4): the row stores the channel-AGNOSTIC pieces — a command-id SUFFIX (the -// tail after the family prefix) and a description PHRASE (the label after the "ReaSampler: -// " lead). The registering shell composes the full, channel-qualified id/name via -// app_version's channelCommandId / channelActionName (commandIdPrefix + suffix / -// actionDisplayPrefix + phrase). This keeps the pure table free of any channel branch: -// stable rebuilds the exact shipped id "CEREBELLUM_REASAMPLER_CAPTURE_TRACK" from -// prefix + "CAPTURE_TRACK"; beta yields "CEREBELLUM_REASAMPLER_BETA_CAPTURE_TRACK". -// -// commandSuffix is FOREVER-STABLE (user keybindings key off the composed id) — never -// change a shipped value. baseName feeds the file stem (sanitized by capture_paths). +// commandSuffix is FOREVER-STABLE (user keybindings key off the composed id). struct CaptureActionDef { const char* commandSuffix; // e.g. "CAPTURE_TRACK" — FOREVER-STABLE (composed w/ prefix) const char* descriptionPhrase; // e.g. "capture selected track(s)" — Actions-list phrase @@ -252,14 +172,11 @@ struct CaptureActionDef { CaptureScope scope; // FX scope (item / track) }; -// The capture-action table. Iterated by main.cpp to register the family and route -// each fired command back to its definition. Kept here (pure) so the taxonomy is -// one testable list, not scattered registration code. +// The capture-action table. Iterated by main.cpp to register the family and +// route each fired command back to its definition. // -// Two rows: CAPTURE_ITEM / CAPTURE_TRACK. There is no master capture — to capture -// the master you render a track. Razor is an inferred range, not a mode, and each -// scope enforces its FX-scope invariant via fxBypassPlanFor. The tail mode each -// capture applies is read from the docked-panel setting, not baked into the row. +// Two rows: CAPTURE_ITEM / CAPTURE_TRACK. There is no master capture — to +// capture the master you render a track. const std::vector& captureActionTable(); } // namespace reasampler::capture diff --git a/src/core/capture/tail_control.cpp b/src/core/capture/tail_control.cpp index 01b9ad8..1c5563a 100644 --- a/src/core/capture/tail_control.cpp +++ b/src/core/capture/tail_control.cpp @@ -1,4 +1,4 @@ -// tail_control — pure implementation. See tail_control.h. NO REAPER / SWELL / vendor. +// tail_control — pure implementation. See tail_control.h. #include "core/capture/tail_control.h" @@ -19,14 +19,10 @@ TailMode cycleTailMode(TailMode current) { } double clampManualMs(double manualMs) { - // Same runaway guard the pure tailRenderSettingsFor applies to Manual: floor a - // negative request to 0, cap at the 8 s ceiling. 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); } @@ -35,8 +31,8 @@ std::string tailToggleLabel(const TailSetting& setting) { 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. + // Clamped so the readout can't 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); @@ -46,17 +42,9 @@ std::string tailToggleLabel(const TailSetting& setting) { 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, riding the shared -// core/json layer (Q-W1, T2-02: the former substring-scan valueAfterKey reader — -// the fifth hand-rolled JSON decoder — is retired). 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 stays forgiving in outcome: any parse failure returns nullopt so -// the caller falls back to a default, exactly as an absent ext-state key does. +// --- JSON round-trip --------------------------------------------------------- +// manualMs round-trips exactly (json::numToStr uses the shortest %.17g-class +// form for doubles); deserialize returns nullopt on any parse failure. namespace { diff --git a/src/core/capture/tail_control.h b/src/core/capture/tail_control.h index fa4ae4b..fba7687 100644 --- a/src/core/capture/tail_control.h +++ b/src/core/capture/tail_control.h @@ -1,13 +1,7 @@ #pragma once // tail_control — the REAPER-free logic behind the docked bank_panel's tail-mode -// toggle. The panel shell (shell/panel/) owns the SWELL window, LICE drawing, and -// click hit-testing; what is NOT DAW-bound — the cycle order, the manual-length -// clamp, and the toggle's label text — lives here so it is unit-tested outside the -// DAW (CLAUDE.md §load-bearing split). Mirror of bank_grid / mode_switch. -// -// PURE MODULE: NO REAPER types, NO SWELL, NO vendor/ includes. Standard library -// only (plus render_settings for the pure TailMode enum). Builds and unit-tests -// without REAPER. +// toggle. The panel shell owns the SWELL window, LICE drawing, and click +// hit-testing; the cycle order, manual-length clamp, and label text live here. #include #include @@ -16,54 +10,40 @@ namespace reasampler::capture { -// 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. Also the value a -// project with no stored tail setting (older / never-adjusted) falls back to on load. +// The Manual-mode starting length: 2s, a musically useful default (a bar of +// reverb throw at moderate tempo), well under the 8s cap. Also the fallback +// for a project with no stored tail setting. 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. +// Fine-adjust step per scroll-wheel notch in Manual mode. Daniel-set. 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 (shell/panel), default -// None so a capture with no explicit choice stays exact-bounds / byte-identical to -// today. `manualMs` is a stored default a future fine-adjust UI can tune; it is -// clamped to the 8 s cap (kMaxTailMs) before it ever reaches a CaptureRequest. +// The panel's current tail setting: mode + the length used only when Manual. +// Default None so a capture with no explicit choice stays exact-bounds. +// `manualMs` is clamped to kMaxTailMs before it ever reaches a CaptureRequest. struct TailSetting { TailMode mode = TailMode::None; double manualMs = kDefaultManualTailMs; }; -// Cycles the tail mode: None -> Auto -> Manual -> None. Pure so the wrap order is -// pinned by a test and the panel's click handler owns no enum arithmetic of its own. -// An out-of-range value (unreachable for a valid enum) cycles back to None. +// Cycles the tail mode: None -> Auto -> Manual -> None. TailMode cycleTailMode(TailMode current); -// The effective manual length a Manual capture uses: `manualMs` clamped to -// [0, kMaxTailMs] (the runaway guard the pure tailRenderSettingsFor also applies). -// Exposed so the panel can show the clamped value and main.cpp hands a pre-clamped -// tailMs into the CaptureRequest. Meaningful only for TailMode::Manual. +// The effective manual length a Manual capture uses: clamped to [0, kMaxTailMs]. +// Exposed so the panel can show the clamped value. Meaningful only for Manual. double clampManualMs(double manualMs); -// 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. +// Applies `notches` scroll-wheel steps of `stepMs` each to `current`, clamped +// to [0, kMaxTailMs]. 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). +// The toggle's label, e.g. "Tail: Off", "Tail: Auto", or (Manual, clamped +// length to one decimal) "Tail: Manual 2.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. +// JSON round-trip of a TailSetting, for persist to store per-project. Pure/ +// testable here, mirroring bank_model's serialize/deserialize; deserialize +// returns nullopt on malformed input so the caller falls back to a default. std::string serializeTailSetting(const TailSetting& setting); std::optional deserializeTailSetting(const std::string& json); diff --git a/src/core/capture/wav_codec.cpp b/src/core/capture/wav_codec.cpp index 99d76fb..a08268e 100644 --- a/src/core/capture/wav_codec.cpp +++ b/src/core/capture/wav_codec.cpp @@ -1,7 +1,6 @@ -// wav_codec — pure implementation. See wav_codec.h. NO REAPER / SWELL / vendor. -// -// The ONE RIFF chunk traversal lives here (nextWavChunk); the layout parse and the -// content hash both walk with it, so their view of the container cannot drift. +// wav_codec — pure implementation. See wav_codec.h. The one RIFF chunk +// traversal lives here (nextWavChunk); layout parse and content hash both +// walk with it, so their view of the container cannot drift. #include "core/capture/wav_codec.h" @@ -12,8 +11,7 @@ namespace reasampler::capture { namespace { -// Little-endian readers. Bounds are checked by the caller before each read; these -// assume `off + N <= bytes.size()`. memcpy avoids alignment/aliasing UB. +// Little-endian readers. Caller checks bounds before each read (off + N <= size). std::uint16_t readU16LE(const std::vector& b, std::size_t off) { return static_cast(b[off] | (b[off + 1] << 8)); } @@ -28,7 +26,7 @@ bool tagEquals(const std::vector& b, std::size_t off, const char* return off + 4 <= b.size() && std::memcmp(b.data() + off, tag, 4) == 0; } -// WAVE format tags we accept as 32-bit float (see wav_codec.h FORMAT ASSUMPTION). +// WAVE format tags we accept as 32-bit float (see wav_codec.h). constexpr std::uint16_t kWaveFormatIeeeFloat = 0x0003; constexpr std::uint16_t kWaveFormatExtensible = 0xFFFE; @@ -37,19 +35,16 @@ constexpr std::uint64_t kFnvOffsetBasis = 14695981039346656037ULL; constexpr std::uint64_t kFnvPrime = 1099511628211ULL; std::string fnvHex(std::uint64_t h) { - // 16-digit lowercase hex (zero-padded) for a fixed-length string. - char buf[17]; + char buf[17]; // 16 hex digits, zero-padded std::snprintf(buf, sizeof(buf), "%016llx", static_cast(h)); return std::string(buf); } -// --- The ONE RIFF chunk traversal -------------------------------------------- +// --- The one RIFF chunk traversal -------------------------------------------- // -// One sub-chunk of a RIFF/WAVE container as the walk sees it: header at -// `headerOffset` (id(4) + size(4)), body at `bodyOffset` with declared `bodySize`. -// `bodyInBounds` is whether the declared body fits inside the buffer — a chunk -// whose declared size lies past the end is still REPORTED (callers decide how to -// treat it) but its body must not be read. +// One sub-chunk of a RIFF/WAVE container: header at `headerOffset` (id(4) + +// size(4)), body at `bodyOffset`/`bodySize`. `bodyInBounds` false means the +// declared body runs past the buffer — still reported, but must not be read. struct WavChunkView { std::size_t headerOffset = 0; std::size_t bodyOffset = 0; @@ -60,9 +55,8 @@ struct WavChunkView { // Advances one chunk. `pos` starts at 12 (after "RIFF" size "WAVE"); each call // fills `out` and moves `pos` past the chunk's body, honoring RIFF even-byte // padding. Returns false when no further chunk header fits. If the padded advance -// would overrun the buffer, the chunk is still reported (return true) and `pos` is -// parked past the end so the NEXT call returns false — exactly the process-then- -// break shape the pre-consolidation walkers shared. +// would overrun the buffer, the chunk is still reported (return true) and `pos` +// is parked past the end so the next call returns false. bool nextWavChunk(const std::vector& bytes, std::size_t& pos, WavChunkView& out) { if (pos + 8 > bytes.size()) return false; @@ -100,8 +94,8 @@ WavLayout parseWavLayout(const std::vector& bytes) { std::uint32_t sampleRate = 0; std::uint16_t extensibleSubFormatTag = 0; // set only when fmtTag == kWaveFormatExtensible - // Walk the sub-chunks after "WAVE" (offset 12) with the shared traversal. A - // malformed/truncated file is "invalid", never an OOB read. + // Walk the sub-chunks after "WAVE" (offset 12). A malformed/truncated file + // is "invalid", never an OOB read. std::size_t pos = 12; WavChunkView c; while (nextWavChunk(bytes, pos, c)) { @@ -112,11 +106,9 @@ WavLayout parseWavLayout(const std::vector& bytes) { channels = readU16LE(bytes, c.bodyOffset + 2); sampleRate = readU32LE(bytes, c.bodyOffset + 4); bitsPerSample = readU16LE(bytes, c.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). + // WAVE_FORMAT_EXTENSIBLE: the real format lives in the SubFormat GUID's + // leading 2-byte tag at body offset 24, not in fmtTag itself. Body must + // reach offset 24+16; otherwise leave the tag at 0 (rejected). if (fmtTag == kWaveFormatExtensible) { if (c.bodySize >= 40 && c.bodyOffset + 40 <= bytes.size()) { extensibleSubFormatTag = readU16LE(bytes, c.bodyOffset + 24); @@ -124,16 +116,13 @@ WavLayout parseWavLayout(const std::vector& bytes) { } haveFmt = true; } else if (tagEquals(bytes, c.headerOffset, "data")) { - // The data chunk: PCM starts at bodyOffset, declared length bodySize. - // Reject if it runs past the buffer (truncated / lying header). + // Reject if the declared body runs past the buffer (truncated/lying + // header), or if data arrived before fmt. if (!c.bodyInBounds) return out; - if (!haveFmt) return out; // data before fmt — not a WAV we parse + if (!haveFmt) return out; - // 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. + // Extensible tag (0xFFFE) is float only when its SubFormat sub-tag is + // also IEEE-float (0x0003) — PCM-integer-in-extensible must be rejected. const bool floatTag = (fmtTag == kWaveFormatIeeeFloat) || (fmtTag == kWaveFormatExtensible && extensibleSubFormatTag == kWaveFormatIeeeFloat); @@ -279,12 +268,6 @@ std::string hashBytes(const std::uint8_t* data, std::size_t len) { } std::string hashWavContent(const std::vector& bytes) { - // Walk the RIFF/WAVE container (the shared traversal) and feed only the `fmt ` - // body and `data` body through FNV-1a, prefixed with the domain-separation tag - // byte 'W' (0x57). Any render-varying metadata chunks (bext, iXML, LIST, SMED, - // etc.) are skipped. If the file does not parse as RIFF/WAVE with both fmt and - // data chunks, fall back to whole-file hashBytes (no prefix) so an unrecognized - // file still gets a hash. if (isRiffWave(bytes)) { std::uint64_t h = kFnvOffsetBasis; auto feedByte = [&](std::uint8_t b) { @@ -295,23 +278,18 @@ std::string hashWavContent(const std::vector& bytes) { bool haveFmt = false; bool haveData = false; - // Domain-separation prefix: 'W' (0x57) distinguishes a content hash from a - // whole-file hash of different bytes that happen to be the same length. - feedByte(static_cast('W')); + feedByte(static_cast('W')); // domain-separation prefix std::size_t pos = 12; WavChunkView c; while (nextWavChunk(bytes, pos, c)) { if (tagEquals(bytes, c.headerOffset, "fmt ")) { - // Feed the entire fmt body (all fields, including format tag, channels, - // sample rate, bits-per-sample — everything that defines the audio format). if (c.bodyInBounds) { for (std::uint32_t i = 0; i < c.bodySize; ++i) feedByte(bytes[c.bodyOffset + i]); haveFmt = true; } } else if (tagEquals(bytes, c.headerOffset, "data")) { - // Feed the entire PCM payload. if (c.bodyInBounds) { for (std::uint32_t i = 0; i < c.bodySize; ++i) feedByte(bytes[c.bodyOffset + i]); diff --git a/src/core/capture/wav_codec.h b/src/core/capture/wav_codec.h index e85aa75..615f2e8 100644 --- a/src/core/capture/wav_codec.h +++ b/src/core/capture/wav_codec.h @@ -1,39 +1,9 @@ #pragma once -// wav_codec — the ONE pure owner of the WAV/RIFF byte format (Q-W3, audit §4e: -// T2-08 / T4-10 / T4-23 consolidation). Chunk walker + layout parse + float32 -// build + size-field patch + the WAV-aware content hash, in one tested module. -// -// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO -// vendor/ includes. Standard library only. Builds and unit-tests without REAPER. -// -// Before this module, RIFF container knowledge (chunk-header arithmetic, even-byte -// padding, size fields) was minted at four sites: wav_trim's layout parse, -// capture_paths' content-hash chunk walk, ingest's hand-built float32 writer, and -// capture_realtime's in-place size patch. A drift in any one (e.g. pad-byte -// handling) would desynchronize hashing from decoding — the dedup-by-hash and -// null-test invariants both sit on this. Now every walker/builder/patcher is here, -// on ONE chunk-traversal implementation. -// -// WHY TRIM 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 does only the file I/O: read the bytes, call the pure parse, run the decay -// scan, call the pure plan, patch + 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). The 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. +// wav_codec — the pure owner of the WAV/RIFF byte format: chunk walker, layout +// parse, float32 build, size-field patch, and the WAV-aware content hash — one +// chunk traversal shared by all of them so hashing and decoding cannot desync. +// Handles 32-bit float WAV only (RIFF/WAVE, `fmt ` tag 3 or 0xFFFE-extensible +// w/ float subformat, float32 `data`); anything else parses as invalid. #include #include @@ -49,22 +19,20 @@ using audio::AudioSample; // --- Layout parse ------------------------------------------------------------ // 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. +// bytes are not a WAV we can safely trim; 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) + std::uint16_t channelCount = 0; // from `fmt ` (interleave stride) + std::uint32_t sampleRate = 0; - // The `data` chunk: byte offset of its first PCM byte within the file, and its - // declared PCM byte length. frameCount = dataByteLength / (channelCount * 4). + // The `data` chunk: PCM byte offset + declared 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). + // Offsets of the two LE uint32 size fields the truncate patch rewrites. std::size_t riffSizeFieldOffset = 4; // always 4 for a RIFF file std::size_t dataSizeFieldOffset = 0; @@ -74,19 +42,15 @@ struct WavLayout { } }; -// 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). +// Parses a WAV byte buffer's header geometry; {valid=false} for anything not a +// canonical float32 RIFF/WAVE, or a `data` length running past the buffer. +// Does not copy PCM, only locates it. Pure + total (no throw, no UB). WavLayout parseWavLayout(const std::vector& bytes); -// Copies `frameCount` interleaved float frames starting at `startFrame` out of the -// WAV's `data` region into a flat [f0c0,f0c1,...] buffer (the shape peaks consumes). -// Clamps to the frames the buffer actually holds — never reads past `data`. Returns -// empty for an invalid layout or an out-of-range start. The floats are read -// little-endian via std::memcpy (no aliasing UB); on a big-endian host they would -// need a byte-swap — flagged, not handled, because the target (Windows/macOS/Linux -// on x86/ARM-LE) is little-endian and REAPER writes LE WAV. +// Copies `frameCount` interleaved float frames starting at `startFrame` out of +// the WAV's `data` region into a flat [f0c0,f0c1,...] buffer, clamped to frames +// actually present; never reads past `data`. Reads little-endian via memcpy — +// target is x86/ARM-LE only, no big-endian byte-swap. std::vector extractFloatFrames(const std::vector& bytes, const WavLayout& layout, std::size_t startFrame, @@ -94,10 +58,8 @@ std::vector extractFloatFrames(const std::vector& byt // --- Truncate plan + size-field patch --------------------------------------- -// 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). +// The plan to truncate a parsed WAV to `keptFrames` frames. `valid` is false if +// the layout is invalid or keptFrames exceeds the file's frames (never grow). struct WavTruncatePlan { bool valid = false; @@ -109,64 +71,37 @@ struct WavTruncatePlan { // 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 -// (patchU32LE), then truncate the file to newFileByteLength. +// Computes the truncate plan to keep exactly `keptFrames` frames. The shell +// applies it: patch the two size fields (patchU32LE), then truncate to +// newFileByteLength. WavTruncatePlan planWavTruncate(const WavLayout& layout, std::size_t keptFrames); -// Patches a little-endian uint32 into a byte buffer at `off` — the RIFF/data size -// fields the truncate plan names. The caller guarantees off + 4 <= bytes.size() -// (the plan's offsets came from a valid parse of the same buffer). +// Patches a little-endian uint32 into a byte buffer at `off`. Caller guarantees +// off + 4 <= bytes.size() (the plan's offsets came from a valid parse of the same +// buffer). void patchU32LE(std::vector& bytes, std::size_t off, std::uint32_t v); // --- Float32 WAV build ------------------------------------------------------- -// Builds a minimal canonical 32-bit-float RIFF/WAVE byte buffer from interleaved -// double samples: RIFF chunk, WAVE form, fmt chunk (tag 3 = WAVE_FORMAT_IEEE_FLOAT, -// 16-byte body), data chunk (interleaved little-endian float32). `nch` channels, -// `rate` Hz, `frameCount` frames (total samples = frameCount * nch). Each double is -// narrowed to float by cast — the bank contract is 32-bit float (see FORMAT -// ASSUMPTION above); the reduction is intentional. The output round-trips through -// parseWavLayout/extractFloatFrames. The ingest shell decodes any non-canonical -// source through REAPER's PCM_source, then writes the bank copy with this. +// Builds a minimal canonical float32 RIFF/WAVE byte buffer from interleaved +// double samples (narrowed to float by cast). Round-trips through +// parseWavLayout/extractFloatFrames. std::vector buildFloat32Wav(int nch, std::uint32_t rate, std::size_t frameCount, const std::vector& interleaved); // --- Content identity (dedup hashes) ----------------------------------------- -// Computes a deterministic FNV-1a 64-bit content hash over `len` bytes at `data` -// and returns it as a 16-character lowercase hex string. Designed to fill -// Sample::contentHash so the confirm-on-last-reference guardrail -// (BankBook::hashReferencedElsewhere) can distinguish "no other bank holds this -// file" from "another bank holds the same file." An empty buffer returns the bare -// FNV-1a 64-bit offset basis in hex (a stable, non-empty sentinel that two empty -// files would share, but real WAV files are never empty). +// Deterministic FNV-1a 64-bit content hash over `len` bytes, as 16-char lowercase +// hex. Fills Sample::contentHash for the confirm-on-last-reference dedup guardrail. std::string hashBytes(const std::uint8_t* data, std::size_t len); -// WAV-aware content hash: hashes only the audio-defining content of a 32-bit-float -// RIFF/WAVE file — the `fmt ` chunk body + the `data` chunk payload — skipping all -// other RIFF chunks (e.g. `bext` origination timestamp, `iXML`, `LIST`/`INFO`, SMED). -// -// WHY: REAPER's offline renderer embeds render-varying metadata chunks (at minimum a -// `bext` chunk containing the origination date/time) even when the format config blob -// requests no BWF metadata. Two renders of identical audio therefore differ in those -// bytes, making whole-file hashes diverge and preventing dedup collapse. -// -// DOMAIN SEPARATION: the FNV-1a input is prefixed with the tag byte 'W' (0x57) before -// the fmt/data bytes are fed in, so a content hash can never equal a whole-file -// hashBytes result for a different file of the same size. -// -// FALLBACK: if `bytes` does not parse as a valid RIFF/WAVE with both a `fmt ` and a -// `data` chunk, the function falls back to whole-file hashBytes (no prefix tag) — -// identical to calling hashBytes(bytes.data(), bytes.size()). This ensures that an -// unrecognized or malformed file still gets a non-empty hash rather than silently -// skipping dedup. -// -// Called by both capture commit paths (offline and realtime) and the ingest import -// in place of the raw hashBytes call. Walks the container with the SAME chunk -// traversal parseWavLayout uses, so hashing and decoding can never desynchronize. +// WAV-aware content hash: hashes only the `fmt ` body + `data` payload, skipping +// other chunks. WHY: REAPER's offline renderer embeds a render-varying `bext` +// timestamp chunk even with no BWF metadata requested, so two renders of +// identical audio would otherwise hash differently and never dedup. Prefixed +// with tag byte 'W' so it can't collide with a same-size hashBytes result. +// Falls back to whole-file hashBytes (no prefix) for a file that doesn't parse. std::string hashWavContent(const std::vector& bytes); } // namespace reasampler::capture diff --git a/src/core/instrument/engine/master_gain.cpp b/src/core/instrument/engine/master_gain.cpp index 02957dc..2d644df 100644 --- a/src/core/instrument/engine/master_gain.cpp +++ b/src/core/instrument/engine/master_gain.cpp @@ -11,7 +11,7 @@ namespace reasampler::instrument::engine { -using util::clamp01; // the ONE unit-interval clamp (Q-W1, T4-24) +using util::clamp01; double masterGainMaxLinear() { return std::pow(10.0, kMasterGainMaxDb / 20.0); } diff --git a/src/core/instrument/engine/master_gain.h b/src/core/instrument/engine/master_gain.h index 42e2b3b..93b7ea1 100644 --- a/src/core/instrument/engine/master_gain.h +++ b/src/core/instrument/engine/master_gain.h @@ -1,19 +1,9 @@ -// master_gain.h — PURE dB<->linear<->knob-taper math for the FB1 post-mixer master gain. -// NO VST3, NO REAPER, NO SWELL/LICE types. The mirror of trigger_seam: one tiny module owns -// the ONE formula both sides of a seam share — here the editor's Gain knob (normalized 0..1) -// and the processor's stored/applied linear gain — so the drawn needle, the persisted value, -// and the audio-thread multiply can never drift. -// -// THE CONTROL (Daniel, FB1). A post-mixer master gain, range -inf .. +24 dB, dB-scaled taper -// with -inf at the BOTTOM of the knob: normalized 0 maps to TRUE ZERO linear gain (silence, -// not a tiny epsilon), and the remaining travel maps linearly in dB from kMasterGainMinDb -// (the finite taper floor) up to kMasterGainMaxDb. Unity (0 dB) sits at norm -// kMasterGainMinDb/(kMasterGainMinDb - kMasterGainMaxDb) ~= 0.714 — most of the throw is -// usable trim, the last stretch is boost. The PERSISTED value is the LINEAR gain (a plain -// finite double, 0 = silence — no -inf on the wire); the taper is a UI-side view of it. -// -// RT DISCIPLINE: the processor applies the linear gain as one multiply over the summed -// output — these functions run on the UI/state threads only. +// master_gain.h — dB<->linear<->knob-taper math for the post-mixer master gain. +// One shared formula so the drawn needle, the persisted value, and the audio-thread +// multiply can't drift. Norm 0 = true zero gain (not an epsilon); persisted value is +// linear gain, the dB taper is a UI-side view of it. Unity (0 dB) sits at ~0.714 norm. +// RT: the processor applies the linear gain as one multiply over the summed output; +// these functions themselves run on UI/state threads only. #pragma once @@ -21,38 +11,28 @@ namespace reasampler::instrument::engine { -// The dB taper endpoints. norm 0 is -inf (true zero); norm just above 0 starts at the -// finite floor kMasterGainMinDb and sweeps linearly in dB to kMasterGainMaxDb at norm 1. +// norm 0 is -inf (true zero); norm just above 0 starts at the finite floor kMasterGainMinDb +// and sweeps linearly in dB to kMasterGainMaxDb at norm 1. inline constexpr double kMasterGainMinDb = -60.0; inline constexpr double kMasterGainMaxDb = 24.0; -// The largest linear gain the control can produce (kMasterGainMaxDb as a ratio, ~15.849). +// Largest linear gain the control can produce (kMasterGainMaxDb as a ratio, ~15.849). double masterGainMaxLinear(); -// Knob taper: normalized [0,1] -> dB. norm <= 0 -> -infinity; else the linear-in-dB sweep -// [kMasterGainMinDb, kMasterGainMaxDb]. norm is clamped to [0,1]. Pure. double masterGainDbFromNorm(double norm); -// Inverse taper: dB -> normalized [0,1]. -infinity (or any dB at or below kMasterGainMinDb, -// including below-floor values like -80 dB) maps to norm 0 (the -inf bottom detent) — the -// finite sweep only covers the range above kMasterGainMinDb; everything at or below it collapses -// to the same true-zero bottom. +24 -> 1. Pure. +// Anything at or below kMasterGainMinDb (including -inf) collapses to norm 0 — the finite +// sweep only covers the range above the floor. double masterGainNormFromDb(double db); -// Knob taper composed with dB->ratio: normalized [0,1] -> LINEAR gain. norm 0 -> exactly -// 0.0 (true silence); norm 1 -> masterGainMaxLinear(). Pure. double masterGainLinearFromNorm(double norm); -// Inverse: LINEAR gain -> normalized [0,1]. linear <= 0 -> 0 (the -inf bottom); a linear at -// or below the kMasterGainMinDb floor (e.g. 0.001 = -60 dB, or anything below) also maps to 0 -// — the floor IS the -inf detent; values between true-zero and the floor cannot be represented -// on the knob and collapse to the bottom. unity -> ~0.714; masterGainMaxLinear() -> 1. -// Out-of-range/non-finite input clamps. Pure. +// linear <= 0, or at/below the kMasterGainMinDb floor, collapses to norm 0 — values between +// true-zero and the floor aren't representable on the knob. Out-of-range/non-finite clamps. double masterGainNormFromLinear(double linear); -// The knob's hover/drag value label for a normalized value: "-inf" at the bottom, else a -// signed one-decimal dB string ("-12.0dB", "+0.0dB", "+2.4dB"). Writes at most `len` bytes -// including the terminator. Pure. +// "-inf" at the bottom, else a signed one-decimal dB string ("-12.0dB", "+2.4dB"). +// Writes at most `len` bytes including the terminator. void formatMasterGainLabel(double norm, char* buf, std::size_t len); } // namespace reasampler::instrument::engine diff --git a/src/core/instrument/engine/pitch_shift.cpp b/src/core/instrument/engine/pitch_shift.cpp index 6bcdae3..8eea981 100644 --- a/src/core/instrument/engine/pitch_shift.cpp +++ b/src/core/instrument/engine/pitch_shift.cpp @@ -1,24 +1,15 @@ -// pitch_shift — pure implementation. See pitch_shift.h for the contract, the S16-F2 -// route-(b) rationale (WDL drags ), and the GA-Preserve root cause that replaced -// the naive dual-tap OLA with correlation-aligned splices. -// NO VST3 / REAPER / SWELL / vendor includes; standard library only. +// pitch_shift — pure implementation. See pitch_shift.h for the contract and regression history. // // Algorithm: a delay ring of 2*window frames. The write head advances one frame per input -// sample (source rate -> duration preserved). ONE active read tap advances by the shift +// sample (source rate, duration preserved). One active read tap advances by the shift // `ratio_` per frame, so its delay behind the writer drifts at (1 - ratio) per frame. When -// that delay leaves the safe band [dLow, dHigh], the tap is RELOCATED by a nominal jump of -// one window (+window toward older content for up-shifts, -window toward the writer for -// down-shifts) — CLAMPED to the filled span so it can never land in unwritten silence (the -// GA2 onset fix) — refined by a cross-correlation search over +/- maxLag PLUS a parabolic -// peak interpolation for a SUB-SAMPLE lag, so the relocated read point is waveform-aligned -// to a fraction of a sample (integer-lag splices left +/-0.5-sample errors: a -59 dB -// sideband comb at the splice cadence on a repitched pure sine — the GA2 "alias lines" on -// the spectrogram). Old and new taps then crossfade over fadeFrames with a raised-cosine, -// amplitude-complementary pair (in-phase content sums to exactly unity gain). For a pure -// sine the correlation snaps the jump to an (integer + fraction) period count, so the output -// stays a single tone at the shifted frequency — the GA-Preserve acceptance bar. At unity -// ratio the delay is frozen mid-band and no splice ever fires: a primed shifter passes the -// stream through with ZERO added latency; a silence-warmed one is a clean window delay. +// that delay leaves the safe band [dLow, dHigh], the tap is relocated by a nominal jump of +// one window — clamped to the filled span so it never lands in unwritten silence — refined +// by a cross-correlation search over +/- maxLag plus a parabolic peak interpolation for a +// sub-sample lag (an integer-only lag left +/-0.5-sample errors: a sideband comb at the +// splice cadence on a repitched pure sine). Old and new taps then crossfade over fadeFrames +// with a raised-cosine, amplitude-complementary pair (in-phase content sums to unity gain). +// At unity ratio the delay is frozen mid-band and no splice ever fires. #include "core/instrument/engine/pitch_shift.h" @@ -55,17 +46,15 @@ void PitchShifter::configure(std::int64_t windowFrames) { ringLen_ = 2 * window_; ring_.assign(static_cast(ringLen_), 0.0f); // Geometry (all quarters of the window): - // - fadeFrames_: the NOMINAL splice crossfade. This window/4 length is only safe when - // the outgoing tap cannot reach the writer before the fade ends; splice() scales the - // live fade length (fadeLen_) down by the current ratio for up-shifts past ~2x, so - // ordinary sampler transpositions (+24 st = ratio 4) never read stale data mid-fade. - // - maxLag_: the alignment search half-range — one window/4 covers a full period of any - // tone down to 4/window cycles-per-frame (~80 Hz at the product's 50 ms window, 44.1k). - // - dLow_/dHigh_: the safe delay band; unity parks the tap mid-band (window/2 delay). - // - corrFrames_: the correlation segment length. At an up-splice the reference segment - // reads FORWARD from the tap at delay ~dLow_, so dLow_-1 frames is exactly what exists - // between the tap and the writer — the cap expresses that safety rather than leaving - // it coincidental. 512 bounds the splice burst. + // - fadeFrames_: nominal splice crossfade; only safe while the outgoing tap can't reach + // the writer before the fade ends. splice() scales fadeLen_ down by ratio for up-shifts + // past ~2x so ordinary transpositions (+24 st) never read stale data mid-fade. + // - maxLag_: alignment search half-range — one window/4 covers a full period of any tone + // down to 4/window cycles-per-frame (~80 Hz at the product's 50 ms window, 44.1k). + // - dLow_/dHigh_: safe delay band; unity parks the tap mid-band (window/2 delay). + // - corrFrames_: at an up-splice the reference segment reads forward from the tap at + // delay ~dLow_, so dLow_-1 is exactly what exists between tap and writer; 512 bounds + // the splice burst. fadeFrames_ = std::max(window_ / 4, 1); maxLag_ = window_ / 4; dLow_ = window_ / 4; @@ -77,10 +66,9 @@ void PitchShifter::configure(std::int64_t windowFrames) { void PitchShifter::reset() { if (window_ > 1) { - // Zero the ring and seed the active tap one window behind the writer — the exact - // middle of the safe band [dLow, dHigh] = [w/4, 2w - w/4], so unity holds it there - // forever and either shift direction has maximal drift room. No history is declared - // (filled_ = 0): follow with prime() or warm() before streaming. + // Seed the active tap one window behind the writer — the exact middle of the safe + // band [dLow, dHigh], so unity holds it there forever with maximal drift room either + // direction. No history declared (filled_ = 0): follow with prime() or warm(). std::fill(ring_.begin(), ring_.end(), 0.0f); writePos_ = 0; posA_ = static_cast(ringLen_ - window_); @@ -104,15 +92,12 @@ void PitchShifter::reset() { void PitchShifter::freezeTail() { if (window_ <= 1 || tailFrozen_) return; tailFrozen_ = true; - // An in-flight crossfade was sized for a RETREATING writer (outgoing tap drains at - // ratio-1 per frame); frozen, the outgoing tap closes at the full ratio. Cap the live - // fade so it completes before tap B reaches the parked writer and reads lapped (oldest- - // window) content mid-fade. fadePos_ is re-anchored to the same fractional t so gNew is - // continuous at the freeze frame (no gain step); see the re-anchor block below. + // An in-flight crossfade was sized for a retreating writer (outgoing tap drains at + // ratio-1 per frame); frozen, it closes at the full ratio instead. Cap the live fade so + // it completes before tap B reaches the parked writer and reads lapped content mid-fade. if (fading_) { // Preserve t = fadePos_/fadeLen_ across the shortening so gNew is continuous at the - // freeze frame (no gain step). Compute tOld BEFORE overwriting fadeLen_, then - // re-anchor fadePos_ to the same fractional position in the new (shorter) fade. + // freeze frame (no gain step). Compute tOld before overwriting fadeLen_. const double tOld = static_cast(fadePos_) / static_cast(fadeLen_); double dB = static_cast(writePos_) - posB_; @@ -133,8 +118,8 @@ void PitchShifter::freezeTail() { void PitchShifter::prime(const AudioSample* src, std::int64_t count) { if (window_ <= 1) return; // pass-through needs no priming - // Clamp to one window: the intended call primes exactly window() frames, and delay == - // count must stay inside the safe band so the seed does not itself trigger a splice. + // Clamp to one window: delay == count must stay inside the safe band so the seed itself + // never triggers a splice. if (count < 0) count = 0; if (count > window_) count = window_; std::fill(ring_.begin(), ring_.end(), 0.0f); @@ -189,24 +174,21 @@ double PitchShifter::readTap(double pos) const { } void PitchShifter::splice(std::int64_t nominalJump, double delay) { - // Relocate the active tap by `nominalJump` frames of ADDED delay (+window_ = jump toward - // older content, -window_ = jump toward the writer), refined by a correlation search so - // the relocated read point is waveform-aligned with the outgoing tap's upcoming content. - // The search is coarse (step 4 over +/- maxLag_) then fine (+/- 3 around the coarse best, - // then a parabolic sub-sample peak): a bounded burst of ~ (maxLag_/2 + 9) * corrFrames_ - // multiply-adds, once per splice. + // Relocate the active tap by `nominalJump` frames of added delay (+window_ = toward older + // content, -window_ = toward the writer), refined by a correlation search so the relocated + // read point is waveform-aligned with the outgoing tap's upcoming content. Search is coarse + // (step 4 over +/- maxLag_) then fine (+/- 3 around the coarse best, then a parabolic + // sub-sample peak): a bounded burst of ~ (maxLag_/2 + 9) * corrFrames_ multiply-adds, once + // per splice. const std::int64_t d = static_cast(delay); - // GA2 onset fix: an up-jump may only relocate into VALID history. The deepest slot the - // search (and the +/-1-lag parabolic refinement calls at bestLag ± 1, and the interpolator's - // read-ahead) can touch is delay d + jump + maxLag + 2 (maxLag from the coarse/fine search, - // +1 for the parabola's outer ± 1 probe, +1 for the interpolator's i1 = i0+1 read-ahead), - // so the tight cap is filled_ - d - maxLag_ - 2. The code uses - 1 here — one sample LOOSER - // than that derived cap (not extra margin); ring indexing wraps via modulo everywhere, so - // this never runs off the physical ring_ array. In steady state (filled_ == ringLen_) this is - // > window_ and the nominal jump is untouched; near a primed onset it shrinks the jump to - // what real history exists (still many source periods with a full-window prime). The floor of - // 1 is only reachable on the documented degenerate reset-without-prime path — garbage-tolerant. + // An up-jump may only relocate into valid history. The deepest slot the search (plus the + // parabola's +/-1 probe and the interpolator's read-ahead) can touch is d + jump + maxLag + 2, + // so the cap is filled_ - d - maxLag_ - 1 (one sample looser than that derived bound, not + // extra margin — ring indexing wraps via modulo everywhere regardless). In steady state + // (filled_ == ringLen_) this exceeds window_ and the nominal jump is untouched; near a primed + // onset it shrinks the jump to what real history exists. The floor of 1 only fires on the + // degenerate reset-without-prime path. std::int64_t jump = nominalJump; if (jump > 0) { const std::int64_t maxJump = filled_ - d - maxLag_ - 1; @@ -233,12 +215,10 @@ void PitchShifter::splice(std::int64_t nominalJump, double delay) { if (++ia >= ringLen_) ia = 0; if (++ic >= ringLen_) ic = 0; } - // NORMALIZED cross-correlation (standard SOLA): a raw dot product is biased toward - // the higher-energy lag, so on a decaying tail every up-splice would prefer the - // loudest candidate over the best-ALIGNED one — a small level step per splice that - // the amplitude-complementary fade cannot hide. The reference segment's energy is - // constant across lags, so dividing by sqrt(Ec) alone ranks identically to the full - // normalized form. A zero-energy candidate scores 0 (splicing into silence is benign). + // Normalized cross-correlation: a raw dot product biases toward the higher-energy lag, + // so on a decaying tail every up-splice would prefer the loudest candidate over the + // best-aligned one. The reference segment's energy is constant across lags, so dividing + // by sqrt(Ec) alone ranks identically to the full normalized form. return ec > 0.0 ? s / std::sqrt(ec) : 0.0; }; @@ -261,13 +241,11 @@ void PitchShifter::splice(std::int64_t nominalJump, double delay) { } } - // SUB-SAMPLE peak (GA2 alias fix): the integer-lag best leaves a residual misalignment of - // up to half a sample; at the splice cadence that residual phase-modulates a pure tone - // into a ~-59 dB sideband comb (the DAW spectrogram "alias lines"). A parabola through - // the scores at bestLag-1/bestLag/bestLag+1 locates the correlation peak to a fraction of - // a sample; readTap()'s linear interpolation realizes the fractional tap position. The - // denominator is negative at a genuine peak — anything else (flat correlation: DC or - // silence) keeps the integer lag, which is already benign there. + // Sub-sample peak: the integer-lag best leaves a residual misalignment of up to half a + // sample, which at the splice cadence phase-modulates a pure tone into an audible sideband + // comb. A parabola through the scores at bestLag-1/bestLag/bestLag+1 locates the peak to a + // fraction of a sample; readTap()'s linear interpolation realizes it. The denominator is + // negative at a genuine peak — flat correlation (DC/silence) keeps the integer lag, benign. double frac = 0.0; { const double sM = scoreAt(bestLag - 1); @@ -287,22 +265,17 @@ void PitchShifter::splice(std::int64_t nominalJump, double delay) { while (p < 0.0) p += len; while (p >= len) p -= len; posA_ = p; - // RATIO-SCALED fade length. At an up-splice the OUTGOING tap starts at ~dLow_ delay and - // keeps draining toward the writer at (ratio - 1) per output frame; the nominal window/4 - // fade only keeps it behind the writer for ratios up to 2. Beyond that (e.g. +24 st = - // ratio 4, an ordinary sampler transposition) it would cross mid-fade and play stale - // read-ahead data at substantial gain — a periodic seam. So cap the live fade at the - // frames of drain headroom actually available, minus 2 (1 for the trigger's sub-dLow_ - // undershoot, 1 for the interpolator's read-ahead). Ratios <= ~2 keep the full nominal - // fade; ratio 4 gets ~window/12 — shorter but still a smooth burst. Down-shifts grow the - // outgoing delay at (1 - ratio) < 1 per frame and cannot reach the ring end within - // window/4 frames, so they always keep the full fade. A pitch-envelope ratio slew - // mid-fade is covered by the same margin for any realistic per-frame bias. + // Ratio-scaled fade length. At an up-splice the outgoing tap keeps draining toward the + // writer at (ratio - 1) per frame; the nominal window/4 fade only keeps it behind the + // writer for ratios up to 2 — beyond that (e.g. +24 st = ratio 4) it would cross mid-fade + // and play stale read-ahead data. Cap the live fade at the drain headroom actually + // available, minus 2 (trigger undershoot + interpolator read-ahead margin). Down-shifts + // drain at (1 - ratio) < 1 per frame and can't reach the ring end within window/4 frames, + // so they always keep the full fade. // - // TAIL-FROZEN (GA3): with the writer parked, the outgoing tap closes on it at the FULL - // ratio (there is no retreating write head), in EITHER shift direction — so the drain - // rate is ratio_ instead of (ratio_ - 1), and the cap applies at every ratio (unity - // included: splices fire in the frozen tail because the delay now drains at unity too). + // Tail-frozen: with the writer parked, the outgoing tap closes on it at the full ratio in + // either shift direction, so the drain rate is ratio_ instead of (ratio_ - 1) and the cap + // applies at every ratio (including unity, since delay now drains at unity too). fadeLen_ = fadeFrames_; const double drainRate = tailFrozen_ ? ratio_ : (ratio_ - 1.0); if (drainRate > 0.0) { @@ -316,16 +289,15 @@ void PitchShifter::splice(std::int64_t nominalJump, double delay) { } fading_ = true; fadePos_ = 0; - // Record the decision for a linked follower channel (T1-01): the follower applies this - // verbatim so both channels share one lag and one splice schedule. + // Record the decision for a linked follower channel — applied verbatim there so both + // channels share one lag and one splice schedule. lastSplice_ = SpliceEvent{true, jump, bestLag, frac, fadeLen_}; } void PitchShifter::applySplice(const SpliceEvent& ev) { - // Follower half of the T1-01 linked lag: relocate + fade with the master's decision, no - // correlation search of our own. The master's jump was clamped against ITS filled_/delay, - // which match ours by the lockstep contract (identical configure/prime/ratio history); - // the fade length likewise derives only from shared geometry + ratio. + // Follower half of the linked lag: relocate + fade with the master's decision, no + // correlation search of our own — the master's jump/fade derive from shared geometry + + // ratio, which match ours by the lockstep contract (identical configure/prime/ratio history). posB_ = posA_; double p = posA_ - static_cast(ev.jump) + static_cast(ev.lag) + ev.frac; const double len = static_cast(ringLen_); @@ -347,24 +319,21 @@ AudioSample PitchShifter::processLinked(AudioSample in, const SpliceEvent& maste AudioSample PitchShifter::processImpl(AudioSample in, const SpliceEvent* linked) { if (window_ <= 1) return in; // pass-through (unconfigured / degenerate) - // Copy the linked decision BEFORE clearing lastSplice_ (guards a self-aliased pointer; - // 5 plain fields, negligible on the RT path). + // Copy the linked decision before clearing lastSplice_ (guards a self-aliased pointer). const SpliceEvent linkedEv = linked != nullptr ? *linked : SpliceEvent{}; lastSplice_ = SpliceEvent{}; // cleared every frame; set again if this frame splices - // 1. Write the incoming sample at the write head (source rate). One more slot of the - // ring now holds valid history (capped at the ring length once it has wrapped). - // TAIL-FROZEN (GA3): the source is exhausted — `in` is padding, not stream. Write - // NOTHING (the ring keeps its all-real final two windows) and hold the write head; - // the read/splice/fade machinery below runs unchanged over the frozen content. + // Tail-frozen: the source is exhausted, `in` is padding, not stream — write nothing (the + // ring keeps its all-real final two windows) and hold the write head; read/splice/fade + // below run unchanged over the frozen content. if (!tailFrozen_) { ring_[static_cast(writePos_)] = in; if (filled_ < ringLen_) ++filled_; } - // 2. Read the active tap; while a splice fade is live, crossfade against the outgoing tap. - // Raised-cosine COMPLEMENTARY gains (gNew + gOld == 1): correlation-aligned content is - // in phase, so the sum holds unity amplitude through the fade (equal-power would bulge). + // Read the active tap; while a splice fade is live, crossfade against the outgoing tap. + // Raised-cosine complementary gains (gNew + gOld == 1): correlation-aligned content is in + // phase, so the sum holds unity amplitude through the fade (equal-power would bulge). double out = readTap(posA_); if (fading_) { const double t = static_cast(fadePos_) / static_cast(fadeLen_); @@ -372,22 +341,17 @@ AudioSample PitchShifter::processImpl(AudioSample in, const SpliceEvent* linked) out = gNew * out + (1.0 - gNew) * readTap(posB_); if (++fadePos_ >= fadeLen_) fading_ = false; } else if (linked != nullptr) { - // 3a. FOLLOWER (T1-01): no trigger test, no search — splice exactly when and how the - // master channel did this frame. Lockstep state means our own trigger would have - // fired on the same frame; applying the master's decision keeps the two rings - // sample-aligned (one shared lag, one shared schedule). + // Follower: no trigger test, no search — splice exactly when and how the master did + // this frame (lockstep means our own trigger would have fired the same frame anyway). if (linkedEv.fired) { applySplice(linkedEv); } else { - // Self-healing fallback (review rider): the master not firing normally means this - // channel's own trigger wouldn't fire either (lockstep). But if the processor ever - // renders a mono block mid-note, this follower channel is skipped for that block - // while the master keeps advancing — its writePos_/filled_ falls behind and, with - // only the `if (linkedEv.fired)` path above, could never resync. So check this - // follower's OWN tap distance against the safe band and splice via its own search - // when it has left [dLow_, dHigh_], exactly as the master would. Reuses splice() — - // no allocation, no new RT cost. In the normal (non-mono-block) case this branch - // never triggers: the master's trigger fires first and this whole `if` is false. + // Self-healing fallback: if the processor ever renders a mono block mid-note, this + // follower is skipped for that block while the master keeps advancing, and could + // never resync via the `linkedEv.fired` path alone. So also check this follower's + // own tap distance against the safe band and splice via its own search when it has + // left [dLow_, dHigh_] — never triggers in the normal (non-mono-block) case, since + // the master's trigger always fires first. double d = static_cast(writePos_) - posA_; const double len = static_cast(ringLen_); while (d < 0.0) d += len; @@ -399,10 +363,10 @@ AudioSample PitchShifter::processImpl(AudioSample in, const SpliceEvent* linked) } } } else { - // 3. Splice scheduling: relocate when the active tap's delay leaves the safe band. - // Up-shifts (ratio > 1) drain the delay toward 0 -> jump one window OLDER; down- - // shifts grow it toward the ring length -> jump one window TOWARD the writer. At - // unity the delay is frozen at window/2 and neither trigger ever fires. + // Splice scheduling: relocate when the active tap's delay leaves the safe band. + // Up-shifts drain the delay toward 0 -> jump one window older; down-shifts grow it + // toward the ring length -> jump one window toward the writer. At unity the delay is + // frozen at window/2 and neither trigger ever fires. double d = static_cast(writePos_) - posA_; const double len = static_cast(ringLen_); while (d < 0.0) d += len; @@ -414,8 +378,7 @@ AudioSample PitchShifter::processImpl(AudioSample in, const SpliceEvent* linked) } } - // 4. Advance heads: write head one frame (source rate; parked while tail-frozen), - // tap(s) by the shift ratio. + // Advance heads: write head one frame (parked while tail-frozen), tap(s) by the shift ratio. if (!tailFrozen_) { ++writePos_; if (writePos_ >= ringLen_) writePos_ = 0; diff --git a/src/core/instrument/engine/pitch_shift.h b/src/core/instrument/engine/pitch_shift.h index f9fa407..ea8fc50 100644 --- a/src/core/instrument/engine/pitch_shift.h +++ b/src/core/instrument/engine/pitch_shift.h @@ -1,66 +1,34 @@ #pragma once -// pitch_shift — a PURE, per-voice, duration-preserving pitch shifter: the S16 "Preserve" -// engine's DSP core. Time-domain delay-line shifter with CORRELATION-ALIGNED SPLICES -// (SOLA-style): one active read tap chases the write head at the shift ratio; when it drifts -// out of its safe delay band it is relocated by a nominal window jump REFINED BY A -// CROSS-CORRELATION SEARCH so the new read point is waveform-aligned, then the old and new -// taps are crossfaded (raised-cosine, amplitude-complementary). Source is consumed 1:1 and -// output produced 1:1 (duration held); only the PITCH changes — an octave up plays the same -// wall-clock length as the root note, unlike the Varispeed `readPos_ += ratio_` resample path. +// pitch_shift — per-voice, duration-preserving pitch shifter (the Preserve engine's DSP core). +// Time-domain delay-line with correlation-aligned splices (SOLA-style): one active read tap +// chases the write head at the shift ratio; when it drifts out of its safe delay band it is +// relocated by a nominal window jump, refined by a cross-correlation search so the new read +// point is waveform-aligned, then old/new taps crossfade (raised-cosine). Source and output are +// both consumed/produced 1:1 — only pitch changes, duration is held (unlike the Varispeed +// `readPos_ += ratio_` resample path). // -// WHY CORRELATED SPLICES (GA-Preserve fix, 2026-07). The first S16 implementation was the -// naive two-tap OLA: taps hard-locked half a window apart, Hann-crossfaded by write-head -// distance. Its taps read the same stream at delays differing by exactly w/2, so their outputs -// carried a FIXED relative phase of 2*pi*f_src*(w/2) — arbitrary and source-frequency- -// dependent. Near anti-phase (roughly half of all frequencies) every crossfade midpoint -// nearly CANCELLED: deep periodic AM + phase slew = strong sidebands. A repitched pure sine -// came out mangled ("multiple partials" on a spectrogram) while the root stayed clean (unity -// freezes the crossfade). The fix is structural: splices must be PHASE-ALIGNED, so each jump -// is snapped to the best waveform match within a bounded lag search — a pure sine's jump -// lands on an integer period count and the output stays a single shifted tone. +// Regression history — do not revert any of these: +// - Correlated splices, vs. the original two-tap OLA (taps hard-locked w/2 apart, Hann +// crossfaded by write-head distance): that fixed offset gave the two taps a fixed relative +// phase, so near-anti-phase source frequencies (roughly half of them) nearly cancelled at +// every crossfade midpoint — a repitched pure sine came out mangled while unity stayed clean. +// Splices must be phase-aligned (snapped to the best waveform match), not just distance-fired. +// - Hand-rolled, not WDL_SimplePitchShifter: its include chain pulls unconditionally, +// which cannot enter this REAPER/VST3-free core (sampler_core_tests links neither SDK). Swap +// to WDL, if ever wanted, happens at the shell, never in this pure core. +// - prime() fills the ring with real upcoming source before streaming starts, not silence: a +// silence-warmed ring made every early splice land in zeros — burst/gap/burst stutter at +// note onset. Since the caller owns the whole decoded sample up front, prime() can know the +// future and gives output frame 0 == source frame 0 with zero structural latency at any ratio. +// - freezeTail() parks the write head once the source is exhausted instead of feeding the last +// real sample as a DC plateau: splices against a flat plateau are unalignable and produced +// ring-modulation-like troughs near the note end. Freezing keeps late splices aligned against +// the ring's real frozen tail. // -// WHY A HAND-ROLLED PURE MODULE, NOT WDL (S16-F2, decided at build). The spec's lean was -// route (a) `WDL_SimplePitchShifter`. But its include chain -// (simple_pitchshift.h -> queue.h -> heapbuf.h -> wdltypes.h) does `#ifdef _WIN32 -> -// #include ` unconditionally, which CANNOT enter the pure sampler_core module -// (CLAUDE.md load-bearing split: NO vendor/host/SDK types; sampler_core_tests links neither -// SDK and compiles outside the DAW). So the Preserve DSP lands as route (b): a house-native -// pure module alongside peaks / wav_codec, CTest-testable, RT-disciplined. Same -// PitchEngine::Preserve contract behind the seam — if WDL is ever preferred it swaps in at -// the SHELL, never in the pure core. -// -// WHY PRIME WITH REAL CONTENT (GA2-Preserve onset fix, 2026-07). Splices RELOCATE the tap -// into ring HISTORY — at note onset a silence-warmed ring has none, so every early splice -// jumped into zeros: a burst/gap/burst stutter for the first ~2 windows of every off-root -// note (the DAW "zero-sample gaps in the first few ms"; at +48 st the ~300 Hz gap cadence -// reads as a square-ish buzz). But this engine is NOT a streaming context: the caller owns -// the whole decoded sample, so the FUTURE of the stream is known at note-on. `prime()` -// pre-fills the ring with the actual first window of upcoming source and parks the tap on -// its oldest frame — output frame 0 IS source frame 0 (zero structural latency at every -// ratio), and `splice()` clamps its jump to the really-filled span so no splice can ever -// land in unwritten silence. -// -// WHY FREEZE THE TAIL (GA3-Preserve tail fix, 2026-07). GA2's prime fixed the ONSET; the -// mirror problem lived at the note END. When the source ran out, the caller held the LAST -// REAL SAMPLE as the feed — a DC plateau with no waveform for the correlation to align on. -// Splices landing in or referenced against it were unalignable, so the tap alternated -// real-tone / dead-DC at the splice cadence, the dead fraction growing as the plateau -// displaced real ring history (the DAW report: periodic troughs "almost like ring -// modulation", ~1:20 tone-to-silence at the very end). freezeTail() removes the padding at -// the source: the WRITER parks, the ring keeps its all-real final two windows, and the -// aligned-splice machinery recycles that frozen tail — a continuous tone until the caller's -// own note end. See freezeTail() below. -// -// PURE MODULE: NO VST3, NO REAPER, NO SWELL, NO vendor/ includes. Standard library only. -// Shares the `AudioSample` float alias from peaks (the one house precedent — sampler_core / -// wav_codec does the same). -// -// RT DISCIPLINE (S16 hard constraint). `configure()` sizes the ring ONCE (off the audio -// thread, at voice allocation). `prime()` / `warm()` only copy into the pre-sized ring -// (bounded, allocation-free — safe on the audio thread at note-on). `process()` does -// NO allocation and NO locks — it reads/writes the pre-sized ring only. The splice-time -// correlation search is a bounded burst of multiply-adds (coarse+refine over a fixed lag -// range) that fires once per splice cadence (window / |ratio-1| frames), never per frame. +// RT discipline: configure() sizes the ring once, off the audio thread. prime()/warm() only +// copy into the pre-sized ring (bounded, allocation-free). process() does no allocation and no +// locks; the correlation search is a bounded burst that fires once per splice cadence +// (window / |ratio-1| frames), never per frame. #include #include @@ -72,100 +40,76 @@ namespace reasampler::instrument::engine { using audio::AudioSample; -// The splice decision made by the most recent process()/processLinked() call — the LINKED-LAG -// stereo contract (Q-W0 T1-01). A stereo voice runs channel 0 as the MASTER (full correlation -// search) and channel 1 as the FOLLOWER: after the master's process() for a frame, the caller -// passes master.lastSplice() to the follower's processLinked() for the SAME frame, and the -// follower applies exactly this decision instead of running its own search. Both channels -// therefore share one lag and one splice schedule (standard stereo SOLA) — per-channel -// independent searches re-drew an inter-channel offset of up to +/-maxLag at every splice: -// stereo image wander at the splice cadence plus comb coloration on any mono sum. +// The splice decision made by the most recent process()/processLinked() call — the linked-lag +// stereo contract. A stereo voice runs channel 0 as the master (full correlation search) and +// channel 1 as the follower: after the master's process() for a frame, the caller passes +// master.lastSplice() to the follower's processLinked() for the same frame, and the follower +// applies exactly this decision instead of running its own search. Both channels therefore +// share one lag and one splice schedule — independent per-channel searches drew an inter-channel +// offset of up to +/-maxLag at every splice, causing stereo image wander and comb coloration on +// a mono sum. struct SpliceEvent { bool fired = false; // a splice was scheduled on this frame - std::int64_t jump = 0; // the CLAMPED nominal jump actually applied (signed) + std::int64_t jump = 0; // the clamped nominal jump actually applied (signed) std::int64_t lag = 0; // correlation best integer lag double frac = 0.0; // parabolic sub-sample refinement, [-0.5, 0.5] std::int64_t fadeLen = 0; // live (ratio-scaled) crossfade length chosen }; -// A per-channel time-domain splice-aligned pitch shifter. One instance transposes ONE channel; -// a stereo voice owns two, LINKED: channel 0 is the master, channel 1 follows its splice -// decisions via processLinked() (see SpliceEvent above) so the two rings stay sample-aligned. +// A per-channel time-domain splice-aligned pitch shifter. A stereo voice owns two, linked: +// channel 0 is the master, channel 1 follows its splice decisions via processLinked() so the +// two rings stay sample-aligned. // -// The default-constructed shifter is INERT: with no configure() it passes input through -// unchanged (shift ratio 1.0, empty ring), so a Varispeed voice that never touches it is -// byte-identical to the pre-S16 engine. +// Default-constructed is inert: with no configure() it passes input through unchanged (ratio +// 1.0, empty ring), so a Varispeed voice that never touches it sees no behavior change. class PitchShifter { public: - // Size the delay ring for `windowFrames` (the nominal splice-jump length; the ring is 2x - // that for splice/search headroom) and derive the fade/search geometry. `windowFrames` - // <= 1 degrades to pass-through (no ring), so a degenerate configure never divides by - // zero or wraps a zero span. Called OFF the audio thread (allocates). Resets all running - // state. A larger window = fewer splices and a deeper alignment search; a PRIMED shifter - // has no added latency regardless (see prime()); the shell picks it from kPreserveWindowMs. + // Sizes the delay ring for `windowFrames` (the ring is 2x that for splice/search headroom). + // <= 1 degrades to pass-through. Off the audio thread (allocates); resets all running state. + // A larger window means fewer splices and a deeper alignment search; a primed shifter has no + // added latency regardless of window size (see prime()). void configure(std::int64_t windowFrames); - // Pre-fill the ring with the first `count` frames of the UPCOMING source stream and park - // the tap on src[0] (delay == count, mid safe band at count == window()). The caller then - // feeds process() the stream CONTINUING at src[count]. Output frame 0 is src[0]: ZERO - // structural latency at every ratio, and splices always have `count` frames of real - // history to land in — the GA2 onset-gap fix. `count` is clamped to [0, window()]. - // When the PLAYABLE source is shorter than one window, prime only the real span and call - // freezeTail() immediately after (Q-W0 T1-03): the GA3 machinery then recycles the real - // short tail. Do NOT pad with silence and declare it valid — padded zeros inside the ring - // are splice targets, re-creating the pre-GA2 burst/gap onset on sub-window material. - // RT-safe: bounded copy into the pre-sized ring, no allocation. No-op when unconfigured. - // The current shift ratio is left untouched. + // Pre-fills the ring with the first `count` frames of the upcoming source stream and parks + // the tap on src[0]; the caller then feeds process() the stream continuing at src[count]. + // `count` is clamped to [0, window()]. If the playable source is shorter than one window, + // prime only the real span and call freezeTail() immediately after — never pad with silence + // and declare it valid; padded zeros are splice targets and reintroduce the onset gap. + // RT-safe: bounded copy, no allocation. No-op when unconfigured; ratio is left untouched. void prime(const AudioSample* src, std::int64_t count); - // prime()-with-silence: zero the ring, park the tap one window behind the writer, and - // declare that window of silence as valid history. Kept for callers with no access to the - // upcoming stream (a silence-primed up-shift plays ~a window of silence before speaking — - // the pre-GA2 onset; the Voice path uses prime() instead). At unity a warmed shifter is a - // bit-exact window() delay. No-op when unconfigured. + // Silence-prime: zero the ring, park the tap one window behind the writer, declare that + // window silence as valid history. Kept for callers with no access to the upcoming stream + // (a silence-primed up-shift plays ~a window of silence before speaking; the Voice path uses + // prime() instead). At unity a warmed shifter is a bit-exact window() delay. void warm(); - // The pitch shift ratio: 2^((note - root)/12) plus any per-frame pitch-envelope bias. - // 1.0 = no shift (pass-through-equivalent output, no splices ever fire). Set per frame is - // fine (cheap); the tap advance simply uses the current value. Values <= 0 are ignored - // (kept at the last valid ratio) so a bad input never runs the tap backward or stalls it. + // 2^((note - root)/12) plus any per-frame pitch-envelope bias; 1.0 = no shift, no splices + // ever fire. Cheap enough to set per frame. Values <= 0 are ignored (kept at the last valid + // ratio) so a bad input never runs the tap backward or stalls it. void setShiftRatio(double ratio); - // Transform ONE input frame into ONE output frame (duration-preserving: 1 in, 1 out). - // RT-safe: reads/writes the pre-sized ring only, no allocation, no lock. When unconfigured - // (window <= 1) returns `in` unchanged (pass-through). Otherwise writes `in` at the write - // head, reads the active tap (crossfading against the outgoing tap while a splice fade is - // live), then advances the write head by one and the tap(s) by the shift ratio. When the - // active tap leaves its safe delay band, a correlation-aligned splice is scheduled. + // Transforms one input frame into one output frame (1 in, 1 out). RT-safe: reads/writes the + // pre-sized ring only, no allocation, no lock. Unconfigured returns `in` unchanged. Otherwise + // writes `in` at the write head, reads the active tap (crossfading against the outgoing tap + // during a splice fade), then advances the write head and tap(s). When the active tap leaves + // its safe delay band, a correlation-aligned splice is scheduled. AudioSample process(AudioSample in); - // FOLLOWER-mode process (Q-W0 T1-01, the stereo linked lag): identical to process() - // except the splice decision is NOT computed here — when `master.fired` is true this - // frame splices with exactly the master's jump/lag/frac/fadeLen; otherwise no splice is - // considered. The caller must process the master channel FIRST each frame and pass its - // lastSplice() here, with both shifters configured/primed/ratio'd identically — their - // ring state then advances in lockstep, so the follower's own trigger would have fired - // on the same frame anyway; skipping its search only removes the second correlation - // burst (strictly cheaper, never costlier). RT-safe: same guarantees as process(). + // Follower-mode process: identical to process() except the splice decision isn't computed + // here — when `master.fired` is true this frame splices with exactly the master's + // jump/lag/frac/fadeLen. The caller must process the master channel first each frame and + // pass its lastSplice() here; both shifters must be configured/primed/ratio'd identically so + // their ring state advances in lockstep. RT-safe: same guarantees as process(). AudioSample processLinked(AudioSample in, const SpliceEvent& master); - // The splice decision made by the most recent process()/processLinked() call (fired == - // false when that frame spliced nothing). Feed to a follower channel's processLinked(). const SpliceEvent& lastSplice() const { return lastSplice_; } - // TAIL WIND-DOWN (GA3, 2026-07). Call when the SOURCE STREAM IS EXHAUSTED — no real frame - // remains to feed process(). Freezes the WRITE head: subsequent process() calls ignore - // their input and write nothing, but read, splice, and crossfade exactly as before over - // the ring's frozen (all-real) final two windows. WHY: the pre-GA3 tail held the last - // real sample as the feed — a DC plateau with no waveform to correlate on. Splices - // landing in or referenced against it were unalignable, so the tap alternated real-tone / - // dead-DC at the splice cadence (the DAW "ring modulation" troughs, growing toward the - // note end as the plateau displaced real history). With the writer frozen the padding - // never enters the ring: every splice stays waveform-aligned against real content and - // the output remains a continuous tone — the final <= one window recycles the frozen - // tail (correlation-aligned, crossfaded) instead of decaying into chopped DC, and the - // caller's own note end (its output-frame anchor) bounds how long that lasts. Idempotent; - // RT-safe (flag + bounded arithmetic, no allocation); cleared by reset()/prime()/warm(). + // Call once the source stream is exhausted — no real frame remains to feed process(). + // Freezes the write head: subsequent process() calls ignore input and write nothing, but + // read/splice/crossfade as before over the ring's frozen (all-real) final two windows, so + // every late splice stays waveform-aligned against real content instead of a DC plateau. + // Idempotent; RT-safe (flag + bounded arithmetic); cleared by reset()/prime()/warm(). void freezeTail(); bool tailFrozen() const { return tailFrozen_; } @@ -188,8 +132,8 @@ private: // the writer (the caller just computed it for the trigger test). Records the decision in // lastSplice_ for a linked follower channel. void splice(std::int64_t nominalJump, double delay); - // Apply a master channel's already-computed splice decision verbatim (no search) — - // the follower half of the T1-01 linked-lag contract. Mirrors it into lastSplice_. + // Applies a master channel's already-computed splice decision verbatim (no search) — + // the follower half of the linked-lag contract. Mirrors it into lastSplice_. void applySplice(const SpliceEvent& ev); // Shared body of process()/processLinked(); `linked` null = master mode (own trigger + // search), non-null = follower mode (splice iff linked->fired, with linked's decision). @@ -210,21 +154,19 @@ private: std::int64_t maxLag_ = 0; // correlation search half-range (window_/4) std::int64_t corrFrames_ = 0; // correlation segment length (dLow_-1, capped at 512, so // the reference read forward from the tap stays behind - // the writer BY CONSTRUCTION at an up-splice) + // the writer by construction at an up-splice) std::int64_t dLow_ = 0; // splice trigger: active-tap delay below this (up-shift) std::int64_t dHigh_ = 0; // splice trigger: active-tap delay above this (down-shift) - std::int64_t filled_ = 0; // frames of VALID history behind the writer (prime count - // + frames streamed, capped at ringLen_). splice() clamps - // its up-jump to this so no splice lands in unwritten - // silence — the GA2 onset-gap fix. + std::int64_t filled_ = 0; // frames of valid history behind the writer; splice() + // clamps its up-jump to this so it never lands in + // unwritten silence double ratio_ = 1.0; // current shift ratio (>0) - SpliceEvent lastSplice_{}; // decision of the most recent process*() frame (T1-01): - // cleared at the top of every frame, set on a splice - bool tailFrozen_ = false; // GA3 wind-down: writer frozen (source exhausted); the tap - // recycles the ring's frozen real tail, splices still - // aligned. With the writer parked, a tap drains toward it - // at ratio_ (not ratio_-1) per frame — splice() scales the - // live fade by that rate. + SpliceEvent lastSplice_{}; // decision of the most recent process*() frame; cleared + // at the top of every frame, set on a splice + bool tailFrozen_ = false; // writer frozen (source exhausted); tap recycles the + // frozen real tail, drains toward the writer at ratio_ + // (not ratio_-1) per frame — splice() scales the fade + // by that rate }; } // namespace reasampler::instrument::engine diff --git a/src/core/instrument/engine/sampler_core.cpp b/src/core/instrument/engine/sampler_core.cpp index 0982462..3903341 100644 --- a/src/core/instrument/engine/sampler_core.cpp +++ b/src/core/instrument/engine/sampler_core.cpp @@ -1,16 +1,11 @@ -// sampler_core — pure sampler engine implementation. See sampler_core.h for the -// contract and the design rationale (keymap resolution, pitch ratio, ADSR shape, -// voice allocation + stealing policy). NO VST3 / REAPER / SWELL / vendor includes. +// sampler_core — pure sampler engine implementation. See sampler_core.h for the contract. // -// DOCUMENTED HOT-PATH EXCEPTION to the Phase Q ~600-line file ceiling (Q-W2v, -// T4-14/T4-27 — Daniel-settled 2026-07-28): this TU deliberately STAYS WHOLE. -// AdsrEnvelope::tick / TriggerEnvelope::amplitudeAt / PitchEnvelope::tick are called -// per-voice-per-sample from Voice::advanceFrame, which is called per-sample from -// VoiceEngine::render — same-TU definition is what lets the compiler inline that -// stack (the build configures NO LTO). A by-class TU split would put the hottest -// inner loop across TU boundaries — the exact heuristic-(3) dispatch blowout the -// phase forbids. Do NOT "fix" this file's length; the header is split instead -// (zone_params.h carries the shared value structs). +// Documented hot-path exception to the ~600-line file ceiling: this TU deliberately stays +// whole. AdsrEnvelope::tick / TriggerEnvelope::amplitudeAt / PitchEnvelope::tick are called +// per-voice-per-sample from Voice::advanceFrame, called per-sample from VoiceEngine::render +// — same-TU definition is what lets the compiler inline that stack (no LTO configured). A +// by-class TU split would put the hottest inner loop across TU boundaries. Do not split +// this file further; the header is split instead (zone_params.h carries the value structs). #include "core/instrument/engine/sampler_core.h" @@ -28,11 +23,8 @@ double pitchRatio(int note, int rootNote) { } double keyTrackedRatio(int note, int rootNote, double keyTrack) { - // Scale the semitone offset by keyTrack before the ET conversion. keyTrack == 1.0 yields - // (note-root)*1.0, which is EXACT in IEEE-754 for an integer-valued double, so the argument - // to std::pow is bit-identical to pitchRatio(note, rootNote) — the 100% default is byte-for- - // byte unchanged from the pre-S-VIEW-6 engine. keyTrack == 0.0 -> offset 0 -> ratio 1.0 on - // every key (no tracking); keyTrack == 2.0 -> doubled offset. Root note stays unity always. + // keyTrack == 1.0 yields (note-root)*1.0, exact in IEEE-754 for an integer-valued double, + // so the argument to std::pow is bit-identical to pitchRatio(note, rootNote). const double semis = static_cast(note - rootNote) * keyTrack; return std::pow(2.0, semis / 12.0); } @@ -105,8 +97,7 @@ double AdsrEnvelope::tick() { const double out = level_; ++framesInStage_; if (framesInStage_ >= params_.attackFrames) { - // S15: Attack -> Hold (holds 1.0 for holdFrames). holdFrames == 0 falls straight - // through Hold on the next tick to Decay, which is EXACTLY the pre-S15 A->D path. + // holdFrames == 0 falls straight through Hold on the next tick to Decay. stage_ = Stage::Hold; framesInStage_ = 0; level_ = 1.0; @@ -115,16 +106,13 @@ double AdsrEnvelope::tick() { } case Stage::Hold: { - // S15 hold stage: level pinned at 1.0 for holdFrames. holdFrames <= 0 leaves the - // stage on this same tick (no frame consumed at 1.0 beyond what Attack already - // emitted), so hold=0 is byte-identical to the pre-S15 envelope. + // holdFrames <= 0 leaves the stage on this same tick (no frame consumed at 1.0 + // beyond what Attack already emitted) so a zero-length hold emits no extra sample. if (params_.holdFrames <= 0) { stage_ = Stage::Decay; framesInStage_ = 0; - // Fall through to Decay this frame so no extra unity sample is emitted for a - // zero-length hold (preserving the exact pre-S15 sample-for-sample shape). level_ = 1.0; - // Single re-dispatch into Decay (bounded: Hold→Decay only; not a general recursion). + // Single re-dispatch into Decay (bounded: Hold->Decay only, not general recursion). return tick(); } level_ = 1.0; @@ -183,7 +171,7 @@ double AdsrEnvelope::tick() { } // --------------------------------------------------------------------------- -// TriggerEnvelope (S15) — a time-boxed fade-in/hold/fade-out amplitude function. +// TriggerEnvelope — a time-boxed fade-in/hold/fade-out amplitude function. // --------------------------------------------------------------------------- void TriggerEnvelope::configure(std::int64_t playLengthFrames, std::int64_t fadeInFrames, @@ -233,7 +221,7 @@ double TriggerEnvelope::amplitudeAt(double sourceOffset) { } // --------------------------------------------------------------------------- -// PitchEnvelope (S16) — AD pitch offset in semitones, off when disabled. +// PitchEnvelope — AD pitch offset in semitones, off when disabled. // --------------------------------------------------------------------------- double PitchEnvelope::tick() { @@ -263,10 +251,10 @@ double PitchEnvelope::tick() { // --------------------------------------------------------------------------- void Voice::presizePreserveShifters(std::int64_t windowFrames) { - // OFF the audio thread (allocates). Both channels are sized so a stereo Preserve voice needs - // no allocation at note-on; a mono Preserve voice simply never process()es shiftR_. The - // prime scratch (one window, reused per channel) is sized here for the same reason: start() - // assembles the first window of the upcoming source stream into it with zero allocation. + // Off the audio thread (allocates). Both channels are sized so a stereo Preserve voice + // needs no allocation at note-on; a mono voice simply never process()es shiftR_. The + // prime scratch is sized here for the same reason: start() assembles the first window + // of the upcoming source into it with zero allocation. shiftL_.configure(windowFrames); shiftR_.configure(windowFrames); primeBuf_.assign(windowFrames > 1 ? static_cast(windowFrames) : 0, 0.0f); @@ -282,20 +270,16 @@ bool Voice::sustainLoopUsable() const { void Voice::start(int note, int velocity, const SampleData& sample, int rootNote, double keyTrack, const VelocityCurve& velocityCurve, bool declickTakeover) { - // Takeover declick (Phase S GA fix, rev 2): BEFORE any state reset, record the PRE-CUT - // REFERENCE — the last rendered output — and mark the compensation PENDING iff this - // start is a takeover/steal of a SOUNDING voice and the caller opted in. The ramp itself - // is seeded on the FIRST frame rendered after the restart, from the DIFFERENCE between - // this reference and the new voice's raw output that frame (seedDeclick), so the - // boundary frame reproduces the old level EXACTLY — whatever the new envelope does - // (Gate attack, zero attack, Trigger's no-fade-in instant-unity onset) and whatever - // value the new sample starts on. [Rev 1 seeded the OLD value here and gated the add by - // (1 − newAmp) in the epilogue: every restart whose new amplitude was instantly ~1 got - // ZERO compensation and kept the full click — exactly the DAW-reported mono-retrig case - // on Trigger / zero-attack zones.] A fresh start (idle voice) clears the declick state — - // no phantom ramp. lastOut{L,R}_ are deliberately NOT zeroed here: a SECOND same-block - // takeover (two steals of this voice with no frame rendered between) must record the - // same pre-cut reference, not a phantom 0. The next rendered frame overwrites lastOut. + // Before any state reset, record the pre-cut reference (last rendered output) and mark + // the compensation pending iff this start is a takeover/steal of a sounding voice and the + // caller opted in. The ramp is seeded on the first frame rendered after the restart, from + // the difference between this reference and the new voice's raw output that frame + // (seedDeclick), so the boundary frame reproduces the old level exactly regardless of the + // new envelope's first value. (An earlier revision gated the add by (1 - newAmp): any + // restart whose new amplitude was instantly ~1 got zero compensation and kept the full + // click.) A fresh start (idle voice) clears the declick state. lastOut{L,R}_ are + // deliberately not zeroed here: a second same-block takeover (two steals with no frame + // rendered between) must record the same pre-cut reference, not a phantom 0. if (declickTakeover && active_) { // Clamp the reference to ±1.0 full scale: a bounded seed whatever the voice was doing. declickRefL_ = (lastOutL_ > 1.0) ? 1.0 : (lastOutL_ < -1.0) ? -1.0 : lastOutL_; @@ -313,14 +297,11 @@ void Voice::start(int note, int velocity, const SampleData& sample, int rootNote releasing_ = false; amplitudeDone_ = false; note_ = note; - // S-VIEW-9: the velocity->amp transfer curve maps MIDI velocity to gain, ONCE at note-on (the - // per-frame render just multiplies the cached velocityGain_ — no new process-thread work). The - // clamp lives inside eval (velocity box-clamped to [0,127]). Replaces the pre-r10 linear - // velocity/127; the default flat y=1 curve (R10-F1 Option A) plays every velocity at unity. + // Velocity->amp mapped once at note-on; the per-frame render just multiplies the cached + // velocityGain_. velocityGain_ = velocityCurve.eval(static_cast(velocity)); - // S-VIEW-6: the key-tracked repitch ratio feeds BOTH engines through baseRatio_ (Varispeed - // read-rate bias and Preserve shift amount both derive from it below). keyTrack == 1.0 is - // the pre-S-VIEW-6 pitchRatio bit-for-bit. + // Feeds both engines through baseRatio_ (Varispeed read-rate bias and Preserve shift + // amount both derive from it below). baseRatio_ = keyTrackedRatio(note, rootNote, keyTrack); sample_ = &sample; @@ -328,25 +309,17 @@ void Voice::start(int note, int velocity, const SampleData& sample, int rootNote playMode_ = p.playMode; pitchEngine_ = p.pitchEngine; - // Initial read position honors the sample's start-point offset (S11), in BOTH modes. Clamp - // into [0, frames): a start at or past the end degrades to 0 (play from the top) rather than - // starting a voice already off the end. A negative start (shouldn't occur) is pinned to 0. + // Clamp into [0, frames): a start at or past the end degrades to 0 (play from the top) + // rather than starting a voice already off the end. const std::int64_t frameCount = static_cast(sample.frames.size()); std::int64_t start = sample.startFrame; if (start < 0 || start >= frameCount) start = 0; readPos_ = static_cast(start); startFrame_ = start; // Trigger fade offset origin (readPos - startFrame = span offset) - // --- Amplitude envelope: Gate = AHDSR (fully per-zone: A/H/D/S/R all read from the zone's - // play.adsr); Trigger = the time-boxed fade-in/out over the % play length. - // - // All five AHDSR fields come from sample.play.adsr (in FRAMES), resolved by - // buildTier0Keymap / buildZonedKeymap at reload time from the stored SECONDS against - // the live sample rate. - // - // Back-compat invariant: a zone whose stored ADSR seconds carry the tier-0 defaults - // (resolved to frames at the live sample rate) sounds identical to the pre-S12 build at - // every DAW rate — now trivially true, since the times are wall-clock seconds. --- + // Amplitude envelope: Gate = AHDSR (all five fields read from the zone's play.adsr, + // resolved to frames from stored seconds at reload time); Trigger = the time-boxed + // fade-in/out over the % play length. if (playMode_ == PlayMode::Gate) { env_.configure(p.adsr); env_.noteOn(); @@ -366,38 +339,34 @@ void Voice::start(int note, int velocity, const SampleData& sample, int rootNote kDefaultFadeCurve); } - // --- Pitch envelope (S16): per-voice AD, off by default (offset always 0). --- pitchEnv_.configure(p.pitchEnv); pitchEnv_.noteOn(); - // --- Preserve engine (S16, GA2 onset fix): PRIME the ALREADY-SIZED per-channel shifters - // with the first window of the ACTUAL upcoming source stream (loop-unrolled under the - // sustain-loop wrap rule, silence past the sample end — that silence IS the true - // stream there). The tap parks on source frame `start`, so the voice speaks on output - // frame 0 at EVERY ratio (no ring-fill silence), and every splice has a full window - // of real history to land in — the fix for the DAW onset zero-gaps (a silence-warmed - // ring made every early splice jump into zeros). The rings and the prime scratch were - // allocated off-thread by presizePreserveShifters (the engine calls it at - // construction); this path is a bounded copy — NO allocation here. Varispeed voices - // never touch the shifters (advanceFrame checks configured()), so a Varispeed - // instrument is byte-identical to pre-S16 and pays no per-frame shifter cost. --- + // Prime the already-sized per-channel shifters with the first window of the actual + // upcoming source stream (loop-unrolled under the sustain-loop wrap rule; silence past + // the sample end, since that silence is the true stream there). The tap parks on source + // frame `start`, so the voice speaks on output frame 0 at every ratio, and every splice + // has a full window of real history to land in — a silence-warmed ring instead makes + // every early splice jump into zeros (burst/gap onset). The rings and prime scratch were + // allocated off-thread by presizePreserveShifters; this path is a bounded copy, no + // allocation. Varispeed voices never touch the shifters, so a Varispeed instrument pays + // no per-frame shifter cost. if (pitchEngine_ == PitchEngine::Preserve && shiftL_.configured()) { const std::int64_t w = shiftL_.window(); const bool loopWrap = sustainLoopUsable(); const SampleLoop& loop = sample.loop; const std::int64_t loopLen = loopWrap ? (loop.end - loop.start) : 0; const bool stereoSample = sample.channelCount() == 2 && shiftR_.configured(); - // Q-W0 T1-03: the prime may only carry PLAYABLE source. The per-frame feed stops at - // feedBound (playEnd_ for a bounded Trigger span, the sample end for Gate) and - // freezes the writer there (GA3) — but the prime used to pull a FULL window bounded - // only by frameCount: a Trigger ring held real PCM past the user's chosen stop (an - // up-shifted tap could play it, transposed, before the voice freed), and a - // shorter-than-window sample got zero padding declared as valid history (splices - // landing in silence — the pre-GA2 burst/gap onset, re-entered for sub-window - // material). So bound the prime by the same playable span and, when that span is - // shorter than a window, freeze the tail IMMEDIATELY after the prime — the GA3 - // machinery then recycles the real short tail, its designed behavior. The sustain- - // loop path is unbounded by construction (the wrap keeps q inside the loop forever). + // The prime may only carry playable source. The per-frame feed stops at feedBound + // (playEnd_ for a bounded Trigger span, the sample end for Gate) and freezes the + // writer there — but a full window bounded only by frameCount would let a Trigger + // ring hold real PCM past the user's chosen stop (an up-shifted tap could play it, + // transposed, before the voice freed), and a shorter-than-window sample would get + // zero padding declared as valid history (splices landing in silence). So bound the + // prime by the same playable span and, when that span is shorter than a window, + // freeze the tail immediately after the prime — that machinery then recycles the + // real short tail. The sustain-loop path is unbounded by construction (the wrap + // keeps q inside the loop forever). const std::int64_t primeBound = (playMode_ == PlayMode::Trigger && playEnd_ > 0 && playEnd_ < frameCount) ? playEnd_ : frameCount; @@ -422,11 +391,11 @@ void Voice::start(int note, int velocity, const SampleData& sample, int rootNote (ch == 0 ? shiftL_ : shiftR_).prime(primeBuf_.data(), primeCount); if (ch == 0) p = q; // capture the end position once from channel 0's walk } - // Per-frame feed continues at `p` (== the feed bound when the prime exhausted the - // playable span — advanceFrame's own exhaustion test then holds from frame 0). + // Per-frame feed continues at `p` (the feed bound when the prime exhausted the + // playable span). feedPos_ = p; if (!loopWrap && primeCount < w) { - // Sub-window playable span: the source is ALREADY exhausted at prime time. + // Sub-window playable span: the source is already exhausted at prime time. shiftL_.freezeTail(); if (stereoSample) shiftR_.freezeTail(); } @@ -447,28 +416,26 @@ void Voice::retune(int note, int rootNote, double keyTrack) { void Voice::release() { if (!active_) return; - // TRIGGER ignores note-off entirely (S15): the one-shot plays through to its play length. - if (playMode_ == PlayMode::Trigger) return; + if (playMode_ == PlayMode::Trigger) return; // Trigger ignores note-off, plays through releasing_ = true; env_.noteOff(); } void Voice::hardStop() { - // CC 120 (All Sounds Off): immediate silence regardless of play mode. Stops Trigger one-shots - // that ignore release(), and short-circuits Gate release tails. RT-safe: no allocation. + // Immediate silence regardless of play mode: stops Trigger one-shots that ignore + // release(), and short-circuits Gate release tails. RT-safe: no allocation. active_ = false; } double Voice::tickAmplitude() { double amp; if (playMode_ == PlayMode::Gate) { - // AHDSR is wall-clock (one tick per output frame), independent of the read rate. amp = env_.tick(); if (env_.finished()) amplitudeDone_ = true; } else { - // Trigger fade shape anchored to the SOURCE offset (readPos - startFrame), so the fades - // land on the same source frames under either engine's read rate. The voice ALSO frees on - // readPos_ >= playEnd_ in advanceFrame; finished() here is the belt to that suspenders. + // Anchored to the source offset so fades land on the same source frames under either + // engine's read rate. The voice also frees on readPos_ >= playEnd_ in advanceFrame; + // finished() here is the belt to that suspenders. amp = trigEnv_.amplitudeAt(readPos_ - static_cast(startFrame_)); if (trigEnv_.finished()) amplitudeDone_ = true; } @@ -476,34 +443,26 @@ double Voice::tickAmplitude() { } void Voice::seedDeclick(double newOutL, double newOutR) { - // First frame after a takeover restart: ARM the bounded blend. The weight starts at 1.0 + // First frame after a takeover restart: arm the bounded blend. The weight starts at 1.0 // so this frame's output is `out*(1-1) + ref*1 == ref` — exact boundary identity whatever // the new envelope's first value. Each subsequent frame adds `w*(ref − outCurrent)` then // decays w, so output is provably bounded by max(|ref|, |outCurrent|) — mid-ramp overshoot - // is impossible even if outCurrent rises while the weight is still significant. - // [Rev 1 stored the frozen difference (ref − x₀); if outₙ rose while that residue was - // still large the sum could exceed full scale. The ±2.0 clamp there was the only guard - // and it silently broke the boundary identity when |x₀| > 1. The bounded blend removes - // both the overshoot hole and the need for a clamp on the stored value.] - // newOutL/R are used only to decide whether an active ramp exists (the seed is purely - // the weight 1.0; ref was clamped to ±1 at start()). The ±2 clamp on the difference is - // gone: the blend formula keeps every output within max(|ref|,|outₙ|) by construction. + // is impossible even if outCurrent rises while the weight is still significant. (An + // earlier revision stored the frozen difference (ref − x₀), which could exceed full scale + // if outₙ rose while that residue was still large.) (void)newOutL; (void)newOutR; // consumed only for the floor guard below declickPending_ = false; - declickWeight_ = 1.0; // ONE weight for both channels (T1-09: the per-R copy was dead state) - // The reference is already clamped to ±1.0 at start() (lines in start(): the ±1 clamp - // on lastOutL_/R_ before storing into declickRefL_/R_). No secondary clamp needed here. - // Activate only when the ref itself is above the floor — if ref ≈ 0 there is nothing to blend. + declickWeight_ = 1.0; // one weight for both channels + // ref is already clamped to ±1.0 at start(). Activate only when it's above the floor — + // if ref ≈ 0 there is nothing to blend. declickActive_ = (declickRefL_ > kDeclickFloor || declickRefL_ < -kDeclickFloor || declickRefR_ > kDeclickFloor || declickRefR_ < -kDeclickFloor); } AudioSample Voice::advanceFrame(bool stereo, AudioSample& outR) { - // Shared read/advance for the mono and stereo paths. The read-head geometry (loop wrap, - // bracketing indices, interpolation partner) is computed ONCE and applied identically to - // every channel — only the PCM value read differs. The amplitude + pitch envelopes tick ONCE - // per frame and scale all channels equally (a voice is one envelope). The head advances by - // exactly one source-frame step per call, so mono and stereo consume the sample at one rate. + // Shared read/advance for the mono and stereo paths: the read-head geometry is computed + // once and applied identically to every channel — only the PCM value read differs. The + // amplitude + pitch envelopes tick once per frame and scale all channels equally. if (!active_ || sample_ == nullptr) { if (stereo) outR = 0.0f; return 0.0f; @@ -516,10 +475,10 @@ AudioSample Voice::advanceFrame(bool stereo, AudioSample& outR) { const bool haveR = stereo && sample_->channelCount() == 2; const std::vector& pcmR = haveR ? sample_->framesR : pcm; - // Loop-aware sustain (GATE only — Trigger is a one-shot with no sustain loop, S15). If a - // valid, non-zero-length loop exists and the read head has advanced past the loop end, wrap - // it back into [start, end). A zero-length loop is treated as "no loop". Under Preserve the - // loop is over the SOURCE read (loop the source, shift the output — S15×S16 contract). + // Loop-aware sustain (Gate only — Trigger is a one-shot with no sustain loop). A valid, + // non-zero-length loop wraps the read head back into [start, end); a zero-length loop is + // "no loop". Under Preserve the loop is over the source read (loop the source, shift the + // output). const SampleLoop& loop = sample_->loop; const bool loopUsable = sustainLoopUsable(); if (loopUsable) { @@ -529,16 +488,15 @@ AudioSample Voice::advanceFrame(bool stereo, AudioSample& outR) { } } - // TRIGGER end: the voice frees once the read head reaches playEnd (source-frame stop). The - // trigger envelope also finishes at the same frame count; either latches the voice idle. + // Trigger frees once the read head reaches playEnd; the envelope also finishes at the + // same count, either latches idle. const bool triggerRanOff = playMode_ == PlayMode::Trigger && readPos_ >= static_cast(playEnd_); - // Ran off the sample end with no usable loop -> voice is done. Peer path of the - // epilogue: an in-flight takeover declick RINGS OUT here instead of hard-cutting — - // dropping it would re-introduce a step on exactly the path the ramp exists for (a - // restart whose new play span ends within the ~4 ms ramp). The voice stays active only - // until the ramp floors; with no declick (the common case, and the entire opt-out - // baseline) this is byte-identical to the plain idle-out. + // Ran off the sample end with no usable loop -> voice is done, except an in-flight + // takeover declick rings out here instead of hard-cutting — dropping it would + // re-introduce a step on exactly the path the ramp exists for (a restart whose new play + // span ends within the ramp). With no declick (the common case) this is byte-identical + // to the plain idle-out. if (triggerRanOff || readPos_ >= static_cast(frameCount)) { if (declickPending_) seedDeclick(0.0, 0.0); // the new output here is silence if (declickActive_) { @@ -561,41 +519,35 @@ AudioSample Voice::advanceFrame(bool stereo, AudioSample& outR) { return 0.0f; } - // Envelopes tick once per output frame. Pitch envelope biases pitch under EITHER engine. + // Envelopes tick once per output frame. Pitch envelope biases pitch under either engine. const double amp = tickAmplitude(); const double gain = amp * velocityGain_; const double pitchEnvSemis = pitchEnv_.tick(); - // The pitch-envelope bias factor 2^(semis/12). When the envelope is off (semis exactly 0) - // this is 1.0 and we skip the pow entirely — the Varispeed-off path stays a bare ratio read - // (no per-frame transcendental), byte-identical to pre-S16. + // 2^(semis/12); when the envelope is off (semis exactly 0) this is 1.0 and skips the pow + // entirely — no per-frame transcendental on the common path. const double envFactor = (pitchEnvSemis == 0.0) ? 1.0 : std::pow(2.0, pitchEnvSemis / 12.0); double outL, outRlocal = 0.0; if (pitchEngine_ == PitchEngine::Preserve && shiftL_.configured()) { - // PRESERVE: feed the shifters the SOURCE stream at unity rate (duration held) and - // TRANSPOSE the output by 2^((note-root + pitchEnvSemis)/12). Pitch envelope adds to - // the shift amount, not the read rate — pitch bends, duration unchanged (S16 - // contract). The feed runs one window AHEAD of readPos_ (the rings were primed with - // that window at start()), under the SAME sustain-loop wrap rule as the anchor, and - // reads integer source frames (readPos_ advances by exactly 1.0 under Preserve, so - // there is nothing to interpolate). Past the last real frame the shifter's writer is - // FROZEN (GA3 wind-down below) — it recycles the real tail it already holds. + // Feed the shifters the source stream at unity rate (duration held) and transpose the + // output by 2^((note-root + pitchEnvSemis)/12) — pitch envelope adds to the shift + // amount, not the read rate. The feed runs one window ahead of readPos_ (the rings + // were primed with that window at start()), under the same sustain-loop wrap rule, + // reading integer source frames (nothing to interpolate). Past the last real frame + // the shifter's writer is frozen — it recycles the real tail it already holds. if (loopUsable) { const std::int64_t loopLen = loop.end - loop.start; while (feedPos_ >= loop.end) feedPos_ -= loopLen; } - // GA3 tail wind-down (supersedes the GA2 hold-last-sample clamp). feedPos_ runs one - // window AHEAD of readPos_; the last real source frame is playEnd_-1 for Trigger (the - // user's chosen stop) or frameCount-1 for Gate (the sample's own end). Once feedPos_ - // reaches that bound the source is EXHAUSTED — GA2 fed the held last sample from here, - // a DC plateau the splice correlation cannot align on (the DAW tail chop: periodic - // troughs at the splice cadence, growing toward the note end as the plateau displaced - // real ring history). Instead FREEZE the shifter's writer: no padding ever enters the - // ring, and the splice machinery keeps recycling the frozen all-real tail, every jump - // still waveform-aligned — a continuous tone through the final window and the release, - // bounded by the voice's own end (readPos_ >= frameCount / playEnd_ frees it). The - // sustain-loop path never gets here: the wrap above keeps feedPos_ < loop.end forever. + // feedPos_ runs one window ahead of readPos_; the last real source frame is + // playEnd_-1 for Trigger or frameCount-1 for Gate. Once feedPos_ reaches that bound + // the source is exhausted — feeding the held last sample instead would give the + // splice correlation a DC plateau it can't align on (periodic troughs at the splice + // cadence, growing toward the note end). Freezing the shifter's writer means no + // padding ever enters the ring, so the splice machinery keeps recycling the frozen + // all-real tail — a continuous tone through the voice's own end. The sustain-loop + // path never gets here: the wrap above keeps feedPos_ < loop.end forever. const std::int64_t feedBound = (playMode_ == PlayMode::Trigger && playEnd_ > 0 && playEnd_ < frameCount) ? playEnd_ : frameCount; diff --git a/src/core/instrument/engine/sampler_core.h b/src/core/instrument/engine/sampler_core.h index e241e1a..eb0955e 100644 --- a/src/core/instrument/engine/sampler_core.h +++ b/src/core/instrument/engine/sampler_core.h @@ -1,142 +1,101 @@ #pragma once -// sampler_core — the HEART of the Phase S MIDI-playback instrument (D3), deliberately -// free of any VST3 *and* any REAPER type so it compiles and unit-tests OUTSIDE the DAW -// and outside any plugin host. It owns the pure sampler engine: polyphonic voice -// allocation with bounded stealing, an ADSR amplitude envelope, a key/velocity keymap -// with (note, velocity) -> zone resolution, and repitch/interpolation from a root note -// with loop-point-aware sustain. +// sampler_core — the polyphonic voice engine: bounded-stealing allocation, an ADSR +// amplitude envelope, a key/velocity keymap resolving (note, velocity) -> zone, and +// repitch/interpolation from a root note with loop-point-aware sustain. // -// PURE MODULE (CLAUDE.md §load-bearing split): NO VST3 types, NO REAPER types, NO SWELL, -// NO vendor/ includes, no include from either SDK. Standard library only. The VST3 shell -// (src/vst/reasampler_processor.cpp) marshals MIDI events + audio buffers to and from -// this core; the core never sees a VST3 ProcessData or a REAPER MediaTrack. Enforced -// structurally: sampler_core_tests links neither SDK (see CMakeLists §2i). -// -// It shares the `AudioSample` float alias from peaks — the one house precedent for a -// pure module leaning on peaks for the audio-domain type (wav_codec does the same). The -// S2 seam fields (root note, loop points) enter as plain int / frame-index inputs; the -// core does no file I/O — it is handed decoded sample frames and produces audio frames. +// Shares the `AudioSample` float alias from peaks. Seam fields (root note, loop points) +// enter as plain int/frame-index inputs; the core does no file I/O. #include #include #include #include -#include "core/audio/peaks.h" // AudioSample (float) -#include "core/instrument/engine/zone_params.h" // per-zone play params + mode enums (Q-W2v header split) -#include "core/instrument/engine/pitch_shift.h" // PitchShifter (S16 Preserve engine DSP core) -#include "core/instrument/engine/velocity_curve.h" // VelocityCurve (S-VIEW-9 velocity->amp transfer curve; eval at start) +#include "core/audio/peaks.h" +#include "core/instrument/engine/zone_params.h" +#include "core/instrument/engine/pitch_shift.h" +#include "core/instrument/engine/velocity_curve.h" namespace reasampler { -// Q-W1 interim: the engine deps live in their sub-namespace homes now; sampler_core -// re-namespaces in its own split wave (Q-W2v). using audio::AudioSample; using instrument::engine::PitchShifter; using instrument::engine::VelocityCurve; using instrument::engine::VelocityPoint; -// The per-zone play-parameter VALUE STRUCTS + per-instance mode enums (ChannelMode / -// VoiceMode / MonoTrigger, AdsrParams / TriggerParams / PitchEnvParams / ZonePlayParams, -// SampleLoop / SampleData, and their constants) live in zone_params.h (Q-W2v header -// split, T4-14/T4-17) so param-reading TUs stop recompiling on engine-class edits. - -// --------------------------------------------------------------------------- -// Keymap — the performance map (instrument-owned, D-B). A note+velocity resolves -// to at most one zone; a zone names which SampleData to play and the root note to -// repitch from. Tier-0 degenerate case: a single zone spanning [0,127] with the -// sample's own root. Tier-1: several zones, each a key range with its own root. +// Keymap — the performance map. A note+velocity resolves to at most one zone; a zone +// names which SampleData to play and the root note to repitch from. Tier-0 degenerate +// case: a single zone spanning [0,127] with the sample's own root. Tier-1: several +// zones, each a key range with its own root. // -// TIER-2 EXTENSION (velocity layers / round-robin) — designed for, not built: -// resolution returns a zone; a zone today owns one sampleIndex. Tier 2 makes a zone -// own a *list* of (velocity-range, sampleIndex) layers (and round-robin sets), and -// resolve() gains the velocity dimension it already receives but currently ignores -// for selection. The (note, velocity) signature and the "resolve to a zone, then a -// sample within it" shape are already in place — Tier 2 fills in the second step -// without changing callers or the voice engine. See the report note. -// --------------------------------------------------------------------------- +// Tier-2 extension (velocity layers/round-robin) — designed for, not built: a zone +// today owns one sampleIndex; Tier 2 would make it own a list of (velocity-range, +// sampleIndex) layers, and resolve() would gain the velocity dimension it already +// receives but currently ignores for selection — no signature change needed. -// A key range [lowNote, highNote] (inclusive both ends) mapping to one sample, with -// the root note to repitch from (defaults to the sample's own root, overridable in -// the performance map per S5). velocityLow/High reserved for Tier-2 layers; today a -// zone accepts the full 1..127 velocity range (0 is note-off by MIDI convention). +// A key range [lowNote, highNote] (inclusive) mapping to one sample, with the root +// note to repitch from (defaults to the sample's own root, overridable per zone). +// velocityLow/High reserved for Tier-2 layers; today a zone accepts the full 1..127 +// velocity range (0 is note-off by MIDI convention). struct KeyZone { int lowNote = 0; int highNote = 127; int rootNote = 60; // repitch reference for this zone - // S-VIEW-6 key-tracking scalar: how far keyboard pitch tracks the root. 1.0 (100%) is - // standard 12-tone-ET (default; bit-identical to pre-S-VIEW-6); 0.0 = no tracking (every - // key plays root pitch); 2.0 = double-rate tracking. Scales the (note-root) semitone offset - // in the repitch math (keyTrackedRatio); rides BOTH engines via the voice's baseRatio_. + // How far keyboard pitch tracks the root: 1.0 = standard 12-tone-ET (default); 0.0 = + // no tracking (every key plays root pitch); 2.0 = double-rate. Scales the (note-root) + // semitone offset in keyTrackedRatio; rides both engines via the voice's baseRatio_. double keyTrack = 1.0; - // S-VIEW-9 velocity->amp transfer curve: maps the note-on velocity (0..127) to the voice's amp - // gain, replacing the fixed linear velocity/127. A per-zone performance characteristic (mirror - // of keyTrack), carried from PerformanceZone by resolvePerformance and eval'd ONCE in - // Voice::start (never per frame). DEFAULT flat y=1 (R10-F1 Option A) — every velocity plays at - // unity, a deliberate behavior change from the pre-r10 linear map. + // Maps note-on velocity (0..127) to the voice's amp gain, eval'd once in Voice::start + // (never per frame). Default flat y=1 — every velocity plays at unity. VelocityCurve velocityCurve = VelocityCurve::flat(); std::size_t sampleIndex = 0; // index into Keymap::samples }; -// Result of resolving a (note, velocity). `matched == false` means the note falls in -// no zone (out-of-zone) — a defined no-play result, NOT an error and NOT voice 0. +// `matched == false` means the note falls in no zone — a defined no-play result, not an +// error and not voice 0. struct ZoneResolution { bool matched = false; std::size_t zoneIndex = 0; // valid only when matched }; -// The keymap: the decoded samples plus the zones that map keys onto them. Owns -// resolution. Pure: no host types. Zones are tested first-match in order, so an -// earlier zone wins an overlap (deterministic, documented). +// Decoded samples plus the zones that map keys onto them. Zones are tested first-match +// in order, so an earlier zone wins an overlap (deterministic, documented). struct Keymap { std::vector samples; std::vector zones; - // Resolves (note, velocity) to a zone. First zone (in order) whose [low,high] - // contains `note` wins. velocity is accepted now (Tier-2 seam) but does not - // affect zone choice at Tier 0-1. Returns {matched=false} when no zone contains - // the note. + // First zone (in order) whose [low,high] contains `note` wins. velocity is accepted + // (Tier-2 seam) but doesn't affect zone choice at Tier 0-1. ZoneResolution resolve(int note, int velocity) const; - // Convenience: build the Tier-0 degenerate keymap — one sample mapped - // chromatically across the whole keyboard from its own root note. + // The Tier-0 degenerate keymap: one sample mapped chromatically across the whole + // keyboard from its own root note. static Keymap singleSampleChromatic(SampleData sample); }; -// The chromatic pitch ratio to play `note` given a sample recorded at `rootNote`: -// 2^((note - rootNote) / 12). note == rootNote -> 1.0 (unity). One octave up -> 2.0, -// one octave down -> 0.5. Pure equal-temperament; no reference-frequency needed. +// 2^((note - rootNote) / 12). note == rootNote -> 1.0. Pure equal-temperament; no +// reference-frequency needed. double pitchRatio(int note, int rootNote); -// The key-tracked pitch ratio (S-VIEW-6): 2^(((note - rootNote) * keyTrack) / 12). The -// keyTrack scalar scales the semitone offset before the ET conversion, so it governs how -// far playback pitch tracks the keyboard around the root: -// keyTrack == 1.0 -> standard 12-tone-ET (BIT-IDENTICAL to pitchRatio(note, rootNote) — -// (note-root)*1.0 is exact in IEEE-754, feeding the same std::pow call). -// keyTrack == 0.0 -> no tracking: every key plays the root pitch (ratio 1.0 for all notes). -// keyTrack == 2.0 -> double-rate tracking: each key is twice as far from the root in pitch. -// At the root note the offset is 0 regardless of keyTrack, so the root always plays at unity. -// Pure; both repitch engines (Varispeed read-rate, Preserve shift-amount) derive from it via -// the voice's baseRatio_. +// 2^(((note - rootNote) * keyTrack) / 12) — keyTrack scales the semitone offset before +// the ET conversion. keyTrack == 1.0 is bit-identical to pitchRatio(note, rootNote) +// ((note-root)*1.0 is exact in IEEE-754, feeding the same std::pow call); 0.0 means every +// key plays the root pitch; 2.0 doubles the tracking rate. At the root note the offset is +// 0 regardless of keyTrack. Both repitch engines derive from it via the voice's baseRatio_. double keyTrackedRatio(int note, int rootNote, double keyTrack); -// --------------------------------------------------------------------------- -// AHDSR amplitude envelope (S15 grows the S3 ADSR with a HOLD stage). Sample-based -// (times in frames), linear segments. A gate: noteOn() enters Attack; noteOff() enters -// Release from wherever it is. Asserted against a known signal in the tests (mirror of peaks). +// AHDSR amplitude envelope, sample-based (times in frames), linear segments. A gate: +// noteOn() enters Attack; noteOff() enters Release from wherever it is. // -// Segment math (all linear ramps): +// Segment math: // Attack: 0 -> 1 over attackFrames -// Hold: hold 1 over holdFrames (S15: NEW stage between A and D) +// Hold: hold 1 over holdFrames // Decay: 1 -> sustainLevel over decayFrames // Sustain: hold sustainLevel until noteOff // Release: currentLevel -> 0 over releaseFrames -// A zero-length attack jumps straight to 1 on the first frame; HOLDFRAMES == 0 skips Hold -// entirely, which is EXACTLY the pre-S15 ADSR (back-compat — existing Gate play is unchanged); -// zero decay jumps to sustain; a noteOff during attack/hold/decay (release-before-sustain) -// releases from the current partial level, not from sustainLevel. AdsrParams is defined above -// (with the other per-zone value structs); this section holds only the per-frame evaluator. -// --------------------------------------------------------------------------- +// A zero-length attack jumps straight to 1 on the first frame; holdFrames == 0 skips Hold +// entirely (the pre-hold-stage ADSR, back-compat); zero decay jumps to sustain; a noteOff +// during attack/hold/decay releases from the current partial level, not from sustainLevel. class AdsrEnvelope { public: @@ -167,30 +126,22 @@ private: double releaseFrom_ = 0.0; // level at the moment noteOff() was called }; -// --------------------------------------------------------------------------- -// S15 Trigger amplitude envelope (per-frame evaluator). The PlayMode / TriggerParams / -// FadeCurve value structs are defined above with the other per-zone params. -// --------------------------------------------------------------------------- - -// Trigger amplitude envelope: a stateless-shape amplitude function over the play span, evaluated -// at a SOURCE-frame offset into the span. Anchoring the fades to SOURCE frames (not output -// frames) is what makes S15 compose with S16: under Preserve the read advances at source rate so -// output and source frames coincide, but under Varispeed a transposed voice consumes source -// faster — driving the fades off the read position keeps the fade-in/out anchored to the SAME -// source frames regardless of engine (the play-length end is a source-frame fact, S15×S16). The -// voice reports the read offset; this maps it to amplitude. Distinct from AHDSR — time-boxed by -// the play length and note-off-immune. Reports finished() once the offset reaches the play length. +// A stateless-shape amplitude function over the play span, evaluated at a source-frame +// offset into the span (not output frames): under Varispeed a transposed voice consumes +// source faster than output, so driving the fades off the read position keeps fade-in/out +// anchored to the same source frames regardless of engine. Distinct from AHDSR — +// time-boxed by the play length and note-off-immune. class TriggerEnvelope { public: - // Configure from the play span + fades. `playLengthFrames` is (playEnd - startFrame): the - // SOURCE-frame length of the play span. Fades are clamped so fadeIn + fadeOut <= playLength - // (fadeOut anchored to the end). A zero/negative play length finishes immediately. + // `playLengthFrames` is (playEnd - startFrame). Fades are clamped so + // fadeIn + fadeOut <= playLength (fadeOut anchored to the end). A zero/negative play + // length finishes immediately. void configure(std::int64_t playLengthFrames, std::int64_t fadeInFrames, std::int64_t fadeOutFrames, FadeCurve curve = kDefaultFadeCurve); - // Amplitude in [0,1] at `sourceOffset` = (readPos - startFrame) source frames into the play - // span. Latches finished() once the offset reaches the play length (>= playLength). Pure over - // the offset (no internal advance) so it composes with either pitch engine's read rate. + // Amplitude in [0,1] at `sourceOffset` = (readPos - startFrame). Latches finished() at + // or past playLength. Pure over the offset so it composes with either pitch engine's + // read rate. double amplitudeAt(double sourceOffset); bool finished() const { return finished_; } @@ -203,21 +154,14 @@ private: bool finished_ = false; }; -// --------------------------------------------------------------------------- -// S16 pitch envelope (per-frame evaluator). The PitchEngine / PitchEnvParams value structs -// and the kDefaultPitchEngine / kPreserveWindowMs constants are defined above. -// --------------------------------------------------------------------------- - -// Per-frame AD pitch-envelope evaluator. tick() returns the CURRENT pitch offset in semitones -// (0 when disabled or past attack+decay), advancing one frame. The voice converts the semitone -// offset to a ratio multiply (Varispeed) or a shift-amount add (Preserve). Pure, unit-tested -// for offset at t=0, peak at t=attack, and 0 at t=attack+decay. +// tick() returns the current pitch offset in semitones (0 when disabled or past +// attack+decay), advancing one frame. The voice converts it to a ratio multiply +// (Varispeed) or a shift-amount add (Preserve). class PitchEnvelope { public: void configure(const PitchEnvParams& params) { params_ = params; pos_ = 0; } void noteOn() { pos_ = 0; } - // Advance one frame, return this frame's pitch offset in semitones. double tick(); private: @@ -225,29 +169,19 @@ private: std::int64_t pos_ = 0; }; -// Takeover declick (Phase S GA fix, rev 2 — audible click when a sounding voice is -// restarted). A takeover restart HARD-CUTS the sounding tone: the read head and envelope -// restart in one frame, a step discontinuity that clicks. This is the same physics on EVERY -// restart-of-a-sounding-voice path — the MONO Retrigger takeover/fallback, the mono -// cross-sample legato restart, and the POLY at-cap voice steal (the editor's preview is a -// plain engine noteOn since the PreviewCard retirement, so a preview re-fire at cap is just -// an at-cap steal). When the caller opts in (start()'s declickTakeover; the engine passes -// it on all of those restart paths when constructed with takeoverDeclick), -// the restart smooths the ACTUAL output discontinuity: start() records the last rendered -// output as the pre-cut reference, and the FIRST frame rendered after the restart seeds a -// compensation equal to (reference − that frame's raw new output). The compensation is -// summed into the output UNGATED and decays by kDeclickDecay per frame, so the boundary -// frame reproduces the old level EXACTLY — zero step whatever the new envelope's first -// value (Gate attack, zero attack, or Trigger's no-fade-in instant-unity onset) and -// whatever value the new sample starts on — and the residue fades in ~2-4 ms to the -80 dB -// floor across 44.1-96 kHz (a per-FRAME DSP micro-ramp, not a stored wall-clock quantity). -// [Rev 1 decayed the OLD output gated by (1 − newAmp): any restart whose new amplitude was -// instantly ~1 — a Trigger zone with no fade-in, a zero-attack Gate — got ZERO compensation -// and kept the full click. The difference seed has no such hole and needs no gate: when old -// and new levels already match, the seed is ~0 and nothing is added, so the +6 dB sum the -// gate defended against is structurally impossible.] OFF by default so the bare core stays -// byte-identical to the pre-fix engine (the regression baseline); the processor shell opts -// in for the engine, mirroring the kDefaultPitchEngine layering. +// Takeover declick: a restart of a sounding voice (mono retrigger takeover/fallback, a +// cross-sample legato restart, or a poly at-cap steal) hard-cuts the old tone in one +// frame — a step discontinuity that clicks. When the caller opts in (start()'s +// declickTakeover), start() records the last rendered output as a pre-cut reference, and +// the first frame after the restart seeds a compensation equal to +// (reference - that frame's raw new output), summed in ungated and decaying by +// kDeclickDecay/frame — so the boundary frame reproduces the old level exactly regardless +// of the new envelope's first value, and the residue fades to the -80 dB floor in a few ms. +// An earlier revision gated the compensation by (1 - newAmp): any restart whose new +// amplitude was instantly ~1 (Trigger with no fade-in, zero-attack Gate) got zero +// compensation and kept the full click — the difference-seed has no such hole. Off by +// default so the bare core stays byte-identical to the pre-fix engine; the processor +// shell opts in. inline constexpr double kDeclickDecay = 0.95; // per-frame decay of the compensation inline constexpr double kDeclickFloor = 1e-4; // below this the ramp is done (~ -80 dB) @@ -259,119 +193,95 @@ inline constexpr double kDeclickFloor = 1e-4; // below this the ramp is done (~ class Voice { public: - // Starts this voice on `note` at `velocity`, playing `sample` (a stable reference - // the caller must keep alive for the voice's lifetime — the Keymap owns it), repitched - // from `rootNote`. All five AHDSR fields (A/H/D/S/R) are read directly from - // sample.play.adsr — the per-zone values (in FRAMES) resolved from the stored seconds by - // buildTier0Keymap / buildZonedKeymap against the live sample rate. The S15 play MODE + - // Trigger params and the S16 pitch ENGINE + pitch envelope are read from `sample.play`. - // The Preserve shifters MUST already be pre-sized (presizePreserveShifters, off-thread) — - // start() only reset()s + warm()s them (RT-safe, no allocation) since it runs on the audio - // thread inside process(). The warm silence pass settles the OLA taps before the first - // output frame (no cold-start click). Byte-identical to the pre-S15 engine when sample.play - // is default (Gate + Varispeed + no pitch env). - // `keyTrack` (S-VIEW-6) scales the (note-root) semitone offset feeding the repitch ratio; - // 1.0 (the default) is standard 12-tone-ET, bit-identical to the pre-S-VIEW-6 baseRatio_. - // `velocityCurve` (S-VIEW-9) maps the note-on velocity to the voice's amp gain, evaluated ONCE - // here (off the per-frame path); defaults to flat y=1 (R10-F1) — every velocity plays at unity. - // `declickTakeover` (Phase S GA fix): when TRUE and this voice is currently ACTIVE (a - // takeover/steal restart, not a fresh start), smooth the restart's output discontinuity — - // the pre-cut output is recorded here and the difference-seeded compensation is armed on - // the first frame rendered after the restart (see the takeover-declick block above - // kDeclickDecay). A fresh start never declicks. + // Plays `sample` (a stable reference the caller must keep alive — the Keymap owns it), + // repitched from `rootNote`. AHDSR/play-mode/pitch-engine params are read from + // sample.play (frames, resolved from stored seconds at keymap build). Preserve shifters + // must already be pre-sized (presizePreserveShifters, off-thread) — start() only + // reset()s + warm()s them (RT-safe, no allocation) since it runs on the audio thread + // inside process(); the warm silence pass settles the OLA taps before the first output + // frame. Byte-identical to the bare engine when sample.play is default. + // `keyTrack` scales the (note-root) semitone offset feeding the repitch ratio; 1.0 is + // standard 12-tone-ET. `velocityCurve` maps note-on velocity to amp gain, evaluated once + // here (off the per-frame path); defaults to flat y=1. `declickTakeover`: when true and + // this voice is currently active (a takeover/steal restart, not a fresh start), arms the + // difference-seeded declick compensation on the first frame after the restart (see + // kDeclickDecay above). A fresh start never declicks. void start(int note, int velocity, const SampleData& sample, int rootNote, double keyTrack = 1.0, const VelocityCurve& velocityCurve = VelocityCurve::flat(), bool declickTakeover = false); - // MONO LEGATO takeover (Phase S): re-pitch this ACTIVE voice to `note` without touching the - // amplitude envelope, the read position, or the shifter state — pitch moves, no re-attack. - // Both engines pick the new baseRatio_ up on the next frame (Varispeed via the read rate, - // Preserve via the per-frame setShiftRatio). No-op on an idle voice. The caller guarantees - // the voice is playing the SAME SampleData the (note-resolved) zone names — a cross-sample - // takeover must restart the voice instead (see MonoTrigger). + // Mono legato takeover: re-pitch this active voice to `note` without touching the + // amplitude envelope, read position, or shifter state — pitch moves, no re-attack. Both + // engines pick the new baseRatio_ up on the next frame. No-op on an idle voice. Caller + // guarantees the voice is playing the same SampleData the resolved zone names — a + // cross-sample takeover must restart the voice instead. void retune(int note, int rootNote, double keyTrack = 1.0); - // Gate off — begins the amplitude release. In GATE mode this enters the AHDSR release; in - // TRIGGER mode it is a NO-OP (Trigger ignores note-off and plays through to its play length). + // Gate off. In Gate mode enters the AHDSR release; in Trigger mode a no-op (Trigger + // ignores note-off and plays through to its play length). void release(); - // HARD STOP — CC 120 (All Sounds Off) semantics. Immediately silences this voice regardless - // of play mode: sets active_ = false with no release ramp. Stops a ringing Trigger one-shot - // instantly (which release() cannot do). RT-safe: no allocation, no lock. + // Hard stop (CC 120 semantics): immediately silences this voice regardless of play mode, + // no release ramp. Stops a ringing Trigger one-shot instantly (release() cannot). + // RT-safe: no allocation, no lock. void hardStop(); - // True while this voice is producing (or about to produce) sound (including any - // declick ring-out tail past the note's playable span). + // True while producing (or about to produce) sound, including any declick ring-out + // tail past the note's playable span. bool active() const { return active_; } - // True while this voice is sounding a PLAYABLE NOTE — active AND the amplitude - // envelope has not yet finished. A voice whose note has run to its end but is still - // ringing out a declick tail is active() but NOT soundingNote(). Use this to - // distinguish "note is alive" (active) from "note occupies a voice slot" (soundingNote) - // for the Preserve-cap count and the mono-Legato takeover predicate — both must ignore - // a ramp-only past-end voice or a new note-on can be dropped / silently muted. + // True while sounding a playable note — active and the amplitude envelope hasn't + // finished. A voice ringing out a declick tail past note end is active() but not + // soundingNote(); the Preserve-cap count and the mono-legato takeover predicate must + // ignore a ramp-only past-end voice or a new note-on could be dropped/silently muted. bool soundingNote() const { return active_ && !amplitudeDone_; } - // The note this voice was started on (for note-off routing). Meaningless if idle. int note() const { return note_; } - // Monotonic age counter — higher = started earlier relative to others. The voice - // engine uses this for its stealing policy (oldest first). Set by the engine. + // Monotonic age counter for the engine's oldest-first stealing policy. Set by the engine. std::uint64_t startOrder() const { return startOrder_; } void setStartOrder(std::uint64_t order) { startOrder_ = order; } bool releasing() const { return releasing_; } - // The S16 pitch engine this voice is running (for the engine's Preserve-voice tally). Only - // meaningful while active(). NOTE: the FA1 unity-shift demotion to Varispeed is GONE — - // it was scoped to the retired PreviewCard, and since GA2 the primed shifter speaks on - // frame 0 at every ratio, so a Preserve voice keeps its shifter at every note (one code - // path, uniform onset across the keyboard). + // The pitch engine this voice is running (for the engine's Preserve-voice tally). Only + // meaningful while active(). PitchEngine pitchEngine() const { return pitchEngine_; } - // The SampleData this voice is playing (nullptr when never started). The engine's mono - // legato path compares it against the new note's resolved sample — a same-sample takeover - // retunes; a cross-sample one restarts. Identity only; callers never mutate through it. + // Identity only, never mutated through; the engine's mono legato path compares it + // against the new note's resolved sample to decide retune vs. restart. const SampleData* playingSample() const { return sample_; } - // Pre-SIZE this voice's Preserve pitch shifters (both channels) to `windowFrames`, OFF the - // audio thread (this allocates; also sizes the prime scratch buffer). The engine calls it - // once at construction so start() — which runs on the audio thread inside process() — never - // allocates: start() only prime()s the already-sized rings with the first window of source - // (a bounded copy). `windowFrames` <= 1 leaves the shifters as pass-through (Varispeed - // instruments pay no ring cost). Idempotent: a re-presize to the same window is a cheap - // no-op in the underlying vector. + // Pre-sizes this voice's Preserve pitch shifters (both channels) to `windowFrames`, off + // the audio thread (allocates; also sizes the prime scratch buffer), so start() — which + // runs inside process() — never allocates. <= 1 leaves the shifters pass-through. + // Idempotent: a re-presize to the same window is a cheap no-op. void presizePreserveShifters(std::int64_t windowFrames); - // Renders one frame's contribution, advancing the read head and envelope by one - // output frame. Returns 0.0 (and goes idle) once the envelope finishes or the - // sample runs out with no loop. The value is already velocity- and - // envelope-scaled — the engine sums voices directly. This is the MONO path (channel - // 0 only) — byte-identical to the pre-S7 engine, so mono play is unchanged. + // Renders one frame's contribution, advancing the read head and envelope by one output + // frame. Returns 0.0 (and goes idle) once the envelope finishes or the sample runs out + // with no loop. Already velocity- and envelope-scaled — the engine sums voices directly. + // Mono path (channel 0 only). AudioSample renderFrame(); - // STEREO render: writes THIS frame's per-channel contribution into `l`/`r` and advances - // the read head + envelope by exactly one frame (the same single advance the mono path - // performs — the envelope ticks ONCE per frame, shared across both channels). For a mono - // sample (channelCount()==1) both `l` and `r` receive the same value (dual-mono / centered). - // Both outputs are already velocity- and envelope-scaled. Goes idle on the same conditions - // as the mono path (envelope finished / sample exhausted with no loop) writing 0 to both. + // Writes this frame's per-channel contribution into `l`/`r` and advances the read head + + // envelope by exactly one frame (the envelope ticks once per frame, shared across both + // channels). A mono sample writes the same value to both (dual-mono/centered). Goes idle + // on the same conditions as the mono path, writing 0 to both. void renderFrameStereo(AudioSample& l, AudioSample& r); private: // Shared read/advance for both render paths: computes the interpolated per-channel // value(s) at the current read head, ticks the amplitude + pitch envelopes once, applies - // the pitch engine (Varispeed read-rate bias OR Preserve shift), advances the head, and - // latches idle on exhaustion. `stereo` selects whether the second channel is read (and - // returned in `outR`); when false `outR` is left untouched. Returns the channel-0 value. + // the pitch engine, advances the head, and latches idle on exhaustion. `stereo` selects + // whether the second channel is read (into `outR`). Returns the channel-0 value. AudioSample advanceFrame(bool stereo, AudioSample& outR); - // This frame's amplitude in [0,1] from the active envelope. GATE: the AHDSR ticks once per - // output frame (independent of the read rate — envelope time is wall-clock). TRIGGER: the - // fade shape is evaluated at the SOURCE offset (readPos - startFrame) so the fades anchor to - // source frames and compose with either pitch engine. Sets amplitudeDone_ when the envelope - // finishes (Gate: release complete; Trigger: play length reached) so advanceFrame frees the voice. + // This frame's amplitude in [0,1] from the active envelope. Gate: AHDSR ticks once per + // output frame (envelope time is wall-clock, independent of read rate). Trigger: fade + // shape is evaluated at the source offset (readPos - startFrame) so fades anchor to + // source frames regardless of pitch engine. Sets amplitudeDone_ on finish so + // advanceFrame frees the voice. double tickAmplitude(); - // True when the sustain loop applies to this voice: GATE mode with a valid, non-empty loop - // inside the sample (S15 — Trigger one-shots never loop). The single source of truth for - // the wrap rule shared by the output anchor (readPos_), the Preserve feed (feedPos_), and - // the start()-time ring prime. + // True when the sustain loop applies: Gate mode with a valid, non-empty loop inside the + // sample (Trigger one-shots never loop). Single source of truth for the wrap rule shared + // by the output anchor, the Preserve feed, and the start()-time ring prime. bool sustainLoopUsable() const; bool active_ = false; @@ -379,13 +289,13 @@ private: int note_ = 0; double velocityGain_ = 1.0; double baseRatio_ = 1.0; // 2^((note-root)/12): the un-modulated repitch ratio - double ratio_ = 1.0; // fractional SOURCE frames advanced per output frame (this frame) + double ratio_ = 1.0; // fractional source frames advanced per output frame (this frame) double readPos_ = 0.0; // fractional frame index into the sample const SampleData* sample_ = nullptr; - // S15 play mode + amplitude envelopes. Gate uses env_ (AHDSR); Trigger uses trigEnv_. Only - // one is active per voice (selected by playMode_ at start). playEnd_ is Trigger's source-frame - // stop (the voice frees when readPos_ >= playEnd_, mirroring the run-off-end idle). + // Gate uses env_ (AHDSR); Trigger uses trigEnv_ — only one active per voice (selected by + // playMode_ at start). playEnd_ is Trigger's source-frame stop (frees when + // readPos_ >= playEnd_). PlayMode playMode_ = PlayMode::Gate; AdsrEnvelope env_; TriggerEnvelope trigEnv_; @@ -393,20 +303,17 @@ private: std::int64_t playEnd_ = 0; // Trigger: source-frame end; Gate: unused bool amplitudeDone_ = false; // set when the active amplitude envelope finished - // S16 pitch engine + pitch envelope. pitchEngine_ selects Varispeed (ratio bias) vs Preserve - // (source-rate read + shifter). shiftL_/shiftR_ transpose the Preserve output per channel - // (one read head, per-channel shift — S7 compose). pitchEnv_ rides EITHER engine. + // pitchEngine_ selects Varispeed (ratio bias) vs Preserve (source-rate read + shifter). + // shiftL_/shiftR_ transpose the Preserve output per channel. pitchEnv_ rides either engine. // - // GA2 onset fix: the shifter rings are PRIMED at start() with the first window of the - // actual upcoming source (loop-unrolled, silence past the end) — output frame 0 is source - // frame `start`, no ring-fill silence, and splices always land in real history. feedPos_ - // is the integer SOURCE frame the shifters are fed next; it runs exactly one window AHEAD - // of readPos_ (the wall-clock output anchor) under the same sustain-loop wrap rule. - // GA3 tail wind-down: once feedPos_ passes the last real frame (Gate: sample end; - // Trigger: playEnd_) the shifters' writers are FROZEN — no padding enters the rings and - // the splice machinery recycles the frozen real tail through the note end (see - // advanceFrame). primeBuf_ is the presized scratch the prime stream is assembled into - // (never touched outside start()). + // The shifter rings are primed at start() with the first window of the actual upcoming + // source (silence past the end) — output frame 0 is source frame `start`, no ring-fill + // silence, and splices always land in real history. feedPos_ is the integer source frame + // fed to the shifters next; it runs exactly one window ahead of readPos_ under the same + // sustain-loop wrap rule. Once feedPos_ passes the last real frame (Gate: sample end; + // Trigger: playEnd_), the shifters' writers freeze — no padding enters the rings and the + // splice machinery recycles the frozen real tail through the note end (see advanceFrame). + // primeBuf_ is the presized scratch the prime stream is assembled into. PitchEngine pitchEngine_ = PitchEngine::Varispeed; PitchEnvelope pitchEnv_; PitchShifter shiftL_; @@ -414,27 +321,24 @@ private: std::int64_t feedPos_ = 0; std::vector primeBuf_; - // Seeds the takeover compensation on the FIRST frame after a restart: the ramp is the - // ACTUAL discontinuity — (pre-cut reference − the new voice's raw output this frame) — - // applied ungated so the boundary frame reproduces the old level exactly. See the - // takeover-declick block above kDeclickDecay. + // Seeds the takeover compensation on the first frame after a restart: the ramp is the + // actual discontinuity — (pre-cut reference - the new voice's raw output this frame) — + // applied ungated so the boundary frame reproduces the old level exactly. void seedDeclick(double newOutL, double newOutR); - // Takeover declick state (see kDeclickDecay above). lastOut{L,R}_ track the voice's most - // recent rendered output (post-gain, incl. any running declick). A takeover/steal start() - // records them as declickRef{L,R}_ (the clamped pre-cut reference) and sets declickPending_; - // the first frame rendered after the restart calls seedDeclick to arm the BOUNDED BLEND: - // outₙ = outₙ*(1−w) + ref*w where w = declickWeight_ (ONE weight, deliberately shared - // by both channels so L/R can never diverge — Q-W0 T1-09 removed the dead per-R copy) - // starts at 1.0 and decays by - // kDeclickDecay each frame. This is algebraically `outₙ + w*(ref − outₙ)`, so the - // boundary frame (w=1) is exactly `ref` and every subsequent output is bounded by - // max(|ref|, |outₙ|) — mid-ramp overshoot is impossible regardless of outₙ rising. - // [Rev 1 stored the frozen difference (ref − x₀); when outₙ rose while that residue - // was still large the sum could exceed full scale by up to ~+3.8 dB.] - // lastOut is NOT zeroed by start() — a second same-block takeover (no frame rendered - // between) must record the same pre-cut reference, not a phantom 0. - // The whole declick state is cleared on a fresh (non-takeover) start. + // lastOut{L,R}_ track the voice's most recent rendered output. A takeover/steal start() + // records them as declickRef{L,R}_ and sets declickPending_; the first frame after the + // restart calls seedDeclick to arm the bounded blend: + // outₙ = outₙ*(1−w) + ref*w, w = declickWeight_ (one weight, shared by both channels so + // L/R can never diverge), starting at 1.0 and decaying by kDeclickDecay each frame. + // Algebraically outₙ + w*(ref − outₙ), so the boundary frame (w=1) is exactly `ref` and + // every subsequent output is bounded by max(|ref|, |outₙ|) — mid-ramp overshoot is + // impossible regardless of outₙ rising. (An earlier revision stored the frozen difference + // (ref − x₀); when outₙ rose while that residue was still large, the sum could exceed + // full scale by several dB.) + // lastOut is not zeroed by start() — a second same-block takeover (no frame rendered + // between) must record the same pre-cut reference, not a phantom 0. The whole declick + // state is cleared on a fresh (non-takeover) start. bool declickPending_ = false; bool declickActive_ = false; double declickRefL_ = 0.0; // clamped pre-cut reference (bounded blend target) @@ -446,50 +350,40 @@ private: std::uint64_t startOrder_ = 0; }; -// --------------------------------------------------------------------------- -// The polyphonic voice engine: a fixed pool of voices, note-on allocation with -// bounded voice stealing, note-off routing, and block rendering (sum of voices). +// The polyphonic voice engine: a fixed pool of voices, note-on allocation with bounded +// voice stealing, note-off routing, and block rendering (sum of voices). // -// VOICE-STEALING POLICY (deterministic, documented): when all voices are busy and a -// new note-on arrives, steal in this priority order: -// 1. the oldest voice already in RELEASE (finishing anyway — cheapest to cut), +// Voice-stealing policy (deterministic, documented): when all voices are busy and a new +// note-on arrives, steal in this priority order: +// 1. the oldest voice already in release (finishing anyway — cheapest to cut), // 2. else the oldest voice overall (longest-held note gives way to the new one). -// "Oldest" = smallest startOrder (assigned monotonically at note-on). This is the -// standard hardware-sampler policy: prefer to sacrifice a dying tail, and failing -// that, the note that has already had the most time. -// --------------------------------------------------------------------------- +// "Oldest" = smallest startOrder (assigned monotonically at note-on) — the standard +// hardware-sampler policy. class VoiceEngine { public: - // Builds an engine with `maxVoices` voices (the polyphony bound) playing from - // `keymap`. The keymap must outlive the engine (the engine holds a reference — it - // reads zones and sample data through it, never copies PCM). Every AHDSR field (A/H/D/S/R) - // + play mode + pitch engine rides on each zone's SampleData::play (in FRAMES, resolved - // from the stored seconds at keymap build); the engine holds no instrument-wide ADSR. - // `preserveVoiceCap` (S16) bounds how many Preserve-engine voices may sound at once (the + // Builds an engine with `maxVoices` voices playing from `keymap` (must outlive the + // engine — held by reference, never copies PCM). Play params ride on each zone's + // SampleData::play; the engine holds no instrument-wide ADSR. + // `preserveVoiceCap` bounds how many Preserve-engine voices may sound at once (the // shifter is materially heavier than Varispeed) — a Preserve note-on beyond the cap is - // dropped rather than glitching; 0 means "no separate Preserve cap" (bounded only by - // maxVoices). `preserveWindowFrames` is the OLA window (in OUTPUT frames) every voice's - // Preserve pitch shifters are PRE-SIZED to at construction (OFF the audio thread), so - // note-on (which runs in process()) never allocates; 0 leaves them pass-through (a - // Varispeed-only instrument pays no ring cost). The processor derives it from the host - // sample rate (kPreserveWindowMs). Defaulted so existing callers (and the pure-core tests) - // are unaffected. + // dropped rather than glitching; 0 means no separate cap (bounded only by maxVoices). + // `preserveWindowFrames` is the OLA window every voice's Preserve shifters are + // pre-sized to at construction (off the audio thread), so note-on never allocates; 0 + // leaves them pass-through. The processor derives it from the host sample rate. // - // `voiceMode` (Phase S): POLY is the pool-with-stealing engine above; MONO drives a single - // voice (voices_[0]) with last-note priority over the held-note stack, per `monoTrigger` - // (Retrigger restarts the envelopes on every takeover/fallback; Legato retunes a same-sample - // takeover without a re-attack). Both default to today's behavior (Poly / Retrigger). The - // engine's config is immutable — a mode/count change rebuilds the engine off-thread through - // the processor's drain-slot reload, so ringing tails survive the swap. + // `voiceMode`: POLY is the pool-with-stealing engine above; MONO drives a single voice + // (voices_[0]) with last-note priority over the held-note stack, per `monoTrigger` + // (Retrigger restarts the envelopes on every takeover/fallback; Legato retunes a + // same-sample takeover without a re-attack). The engine's config is immutable — a + // mode/count change rebuilds the engine off-thread through the processor's drain-slot + // reload, so ringing tails survive the swap. // - // `takeoverDeclick` (Phase S GA fix): when TRUE, every RESTART of a SOUNDING voice — - // the MONO Retrigger takeover, the retrigger fallback on note-off, the cross-sample - // legato restart, and the POLY at-cap voice STEAL — seeds the per-voice declick ramp - // (see kDeclickDecay) so the hard cut of the old tone does not click. start() self-gates - // on the voice being active, so a fresh start (free voice) never ramps. Default FALSE - // keeps the bare core byte-identical to the pre-fix engine (regression baseline); the - // processor shell opts in — the same layering as the kDefaultPitchEngine product default. + // `takeoverDeclick`: when true, every restart of a sounding voice (mono retrigger + // takeover/fallback, cross-sample legato restart, poly at-cap steal) seeds the + // per-voice declick ramp (see kDeclickDecay) so the hard cut doesn't click. start() + // self-gates on the voice being active, so a fresh start never ramps. Default false + // keeps the bare core byte-identical to the pre-fix engine; the processor shell opts in. VoiceEngine(std::size_t maxVoices, const Keymap& keymap, std::size_t preserveVoiceCap = 0, std::int64_t preserveWindowFrames = 0, VoiceMode voiceMode = VoiceMode::Poly, @@ -507,42 +401,32 @@ public: // the older tail to ring — matches hardware behavior). No-op if none match. void noteOff(int note); - // CC 123 — MIDI All-Notes-Off: clears the MONO held stack and RELEASES every active voice - // (Gate voices enter their AHDSR release tail; Trigger one-shots ignore release and play - // through their bounded play length). This is the mono stack's ONLY reset path — a phantom - // entry left by a lost note-off would otherwise be resurrected by the fallback and sustain - // forever with no key held. RT-safe (no allocation, bounded by maxVoices). + // CC 123 (All-Notes-Off): clears the mono held stack and releases every active voice + // (Gate enters AHDSR release; Trigger ignores release and plays through). The mono + // stack's only reset path — a phantom entry left by a lost note-off would otherwise be + // resurrected by the fallback and sustain forever with no key held. RT-safe. void allNotesOff(); - // CC 120 — MIDI All-Sounds-Off: hard-stops EVERY voice immediately (active_ = false, no - // release ramp), clears the MONO held stack, and silences even Trigger one-shots that would - // ignore a release. Use for panic; CC 123 for the softer "let gates release" behavior. - // RT-safe (no allocation, bounded by maxVoices); callable from the audio thread. + // CC 120 (All-Sounds-Off): hard-stops every voice immediately, clears the mono held + // stack, silences even Trigger one-shots that would ignore a release. Panic; CC 123 is + // the softer "let gates release." RT-safe, callable from the audio thread. void allSoundsOff(); - // REAL-TIME render (S4): sums all active voices into the caller-provided buffer - // `out[0..frameCount)`, ADDING to whatever is there (the caller clears or mixes — - // this never touches memory it does not own and NEVER allocates). This is the - // audio-thread entry point: the VST3 process callback passes the host's own output - // channel buffer, so no allocation, resize, or heap traffic happens under process. - // Voices that finish mid-block go idle and stop contributing. `out` must point at - // at least `frameCount` writable samples; a null `out` or zero count is a no-op. + // Sums all active voices into the caller-provided buffer `out[0..frameCount)`, adding + // to whatever is there — never allocates (the audio-thread entry point; the VST3 + // process callback passes the host's own output buffer). Voices that finish mid-block + // go idle. `out` must point at least `frameCount` writable samples; null/zero is a no-op. void render(AudioSample* out, std::size_t frameCount); - // REAL-TIME stereo render (S7): sums all active voices per-channel into the caller's two - // buffers `left`/`right` (each `frameCount` writable samples), ADDING to whatever is there - // (the caller clears/mixes). Same RT discipline as the mono overload — no allocation, no - // resize, no lock. A mono sample plays dual-mono (same value to both channels, centered); - // a stereo sample plays its two channels. A null buffer or zero count is a no-op. The mono - // and stereo render paths are independent output shapes over the SAME voice pool; the active - // channel mode (mono vs stereo bus) picks which one the process callback drives per block. + // Stereo overload: sums per-channel into `left`/`right`, same RT discipline. A mono + // sample plays dual-mono (same value both channels); a stereo sample plays its two + // channels. Mono and stereo render are independent output shapes over the same voice + // pool — the active channel mode picks which one the process callback drives per block. void render(AudioSample* left, AudioSample* right, std::size_t frameCount); - // TEST / off-thread convenience: appends `frameCount` summed frames to `out` - // (grows it — DO NOT call on the audio thread; it allocates). Delegates to the - // real-time overload after sizing the buffer, so both paths share one mix loop. - // Does not clear existing contents — appends, matching the pre-S4 contract the - // unit tests rely on. + // Test/off-thread convenience: appends `frameCount` summed frames to `out` (grows it — + // do not call on the audio thread). Delegates to the real-time overload after sizing + // the buffer. Does not clear existing contents — appends. void render(std::vector& out, std::size_t frameCount); // Count of currently active voices (for tests / diagnostics). @@ -557,49 +441,45 @@ private: // one per the documented policy. Always returns a valid index (maxVoices >= 1). std::size_t allocateVoice(); - // Count of active Preserve-engine voices (for the S16 Preserve cap). Rescanned per note-on + // Count of active Preserve-engine voices (for the Preserve cap). Rescanned per note-on // (cheap: bounded by maxVoices) rather than maintained as a running tally. std::size_t activePreserveVoices() const; - // --- MONO mode (Phase S): last-note priority over a held-note stack ------------ - // The stack holds every currently-held, ZONE-RESOLVING note in press order (top = most - // recent = the sounding note while the voice is gated). An out-of-zone note never joins - // (it cannot sound, so it must not later take the voice back on a fallback). Re-pressing - // a held note moves it to the top. Fixed-capacity (128 distinct MIDI notes) — no - // allocation on the audio thread. Velocity is kept per held note so a RETRIGGER fallback - // re-strikes the fallen-back-to note at ITS original velocity. + // Mono mode: last-note priority over a held-note stack. The stack holds every + // currently-held, zone-resolving note in press order (top = most recent = the sounding + // note). An out-of-zone note never joins (it cannot sound, so it must not later take + // the voice back on a fallback). Re-pressing a held note moves it to the top. + // Fixed-capacity (128 distinct MIDI notes) — no allocation on the audio thread. + // Velocity is kept per held note so a retrigger fallback re-strikes at its original + // velocity. struct HeldNote { std::uint8_t note; std::uint8_t velocity; }; - // Mono note-on: push to the stack and take the voice over (legato retune on a same-sample - // takeover, else a fresh start). Returns 0 (the mono voice) or kNoVoice for out-of-zone - // or out-of-range (note outside [0,127] — rejected BEFORE the stack, which stores uint8). - // The S16 Preserve cap is NOT applied in mono — a single voice runs at most one shifter, - // inherently within any cap; applying it would wrongly drop a Preserve->Preserve takeover. + // Push to the stack and take the voice over (legato retune on a same-sample takeover, + // else a fresh start). Returns 0 (the mono voice) or kNoVoice for out-of-zone or + // out-of-range (rejected before the stack, which stores uint8). The Preserve cap is + // not applied in mono — a single voice runs at most one shifter, inherently within any + // cap; applying it would wrongly drop a Preserve->Preserve takeover. std::size_t monoNoteOn(int note, int velocity); - // Mono note-off: pop from the stack; if the released note was sounding, fall back to the - // most-recent still-held note (retrigger or legato per monoTrigger_), else release. + // Pop from the stack; if the released note was sounding, fall back to the most-recent + // still-held note (retrigger or legato per monoTrigger_), else release. void monoNoteOff(int note); // Drops `note` from the held stack (order of the remaining notes preserved). No-op if absent. void removeHeld(int note); std::vector voices_; const Keymap& keymap_; - std::size_t preserveVoiceCap_ = 0; // S16: max simultaneous Preserve voices (0 = no separate cap) + std::size_t preserveVoiceCap_ = 0; // max simultaneous Preserve voices (0 = no separate cap) std::uint64_t nextStartOrder_ = 1; // monotonic; 0 reserved for "never started" VoiceMode voiceMode_ = VoiceMode::Poly; MonoTrigger monoTrigger_ = MonoTrigger::Retrigger; - bool takeoverDeclick_ = false; // GA fix: declick every restart/steal of a sounding voice + bool takeoverDeclick_ = false; // declick every restart/steal of a sounding voice std::array heldStack_{}; // mono held notes, press order; top = heldCount_-1 std::size_t heldCount_ = 0; }; -// NOTE (preview redesign): the Phase S PreviewCard — a dedicated preview voice isolated -// from the MIDI pool — is RETIRED. The editor's preview trigger is now a synthetic note-on -// at the loaded capture's root note through the SAME VoiceEngine host MIDI drives, so a -// preview is a real voice: it counts against the voice count, can steal / be stolen, and -// respects Poly/Mono + Retrigger/Legato (a deliberate reversal of the earlier isolation -// decision). The FA1 unity-Varispeed demotion in Voice::start went with it — since the GA2 -// prime fix the shifter speaks on frame 0 at every ratio, so the demotion bought nothing -// but a second code path. +// The editor's preview trigger is a synthetic note-on at the loaded capture's root note +// through the same VoiceEngine host MIDI drives, so preview is a real voice: it counts +// against the voice count, can steal/be stolen, and respects Poly/Mono + Retrigger/Legato. +// There is no dedicated preview voice isolated from the MIDI pool. } // namespace reasampler diff --git a/src/core/instrument/engine/velocity_curve.cpp b/src/core/instrument/engine/velocity_curve.cpp index 50af883..033cfa2 100644 --- a/src/core/instrument/engine/velocity_curve.cpp +++ b/src/core/instrument/engine/velocity_curve.cpp @@ -2,20 +2,19 @@ #include "core/instrument/engine/velocity_curve.h" -#include // std::max, std::min, std::abs, std::stable_sort -#include // std::fabs -#include // std::move +#include +#include +#include namespace reasampler::instrument::engine { namespace { - double clampVelocity(double v) { return std::clamp(v, kVelMin, kVelMax); } double clampAmp(double a) { return std::clamp(a, kAmpMin, kAmpMax); } -// Pixel<->box maps (mirror of envelope_edit's timeToX/levelToY). X spans the width for [0,127]; Y -// spans (height-1) rows for amp [0,1] with amp 1 at the TOP (y increases downward). +// X spans the width for [0,127]; Y spans (height-1) rows for amp [0,1] with amp 1 at the TOP +// (pixel y increases downward, so this axis is inverted relative to amp). double velPerPixel(const VelocityCurve::Box& box) { const int w = std::max(0, box.width); if (w <= 0) return 0.0; @@ -35,7 +34,6 @@ int velToX(const VelocityCurve::Box& box, double velocity) { int ampToY(const VelocityCurve::Box& box, double amp) { const int h = std::max(0, box.height); if (h <= 1) return box.top; - // amp 1 at top (box.top), amp 0 at bottom (box.top + h - 1). const double frac = (clampAmp(amp) - kAmpMin) / (kAmpMax - kAmpMin); return box.top + static_cast((1.0 - frac) * static_cast(h - 1) + 0.5); } @@ -44,19 +42,19 @@ int ampToY(const VelocityCurve::Box& box, double amp) { VelocityCurve VelocityCurve::flat() { VelocityCurve c; - c.points_ = {{kVelMin, kAmpMax}, {kVelMax, kAmpMax}}; // y = 1 everywhere (R10-F1 Option A) + c.points_ = {{kVelMin, kAmpMax}, {kVelMax, kAmpMax}}; return c; } VelocityCurve VelocityCurve::linear() { VelocityCurve c; - c.points_ = {{kVelMin, kAmpMin}, {kVelMax, kAmpMax}}; // y = velocity/127 + c.points_ = {{kVelMin, kAmpMin}, {kVelMax, kAmpMax}}; return c; } VelocityCurve VelocityCurve::fromPoints(std::vector pts) { - // Box-clamp every point, then stable-sort by velocity (X-order; stable so coincident-X points - // keep their wire order). A stable sort keeps the eval well-defined for duplicate-X knots. + // Stable sort so coincident-X points keep their wire order (eval stays well-defined for + // duplicate-X knots). for (VelocityPoint& p : pts) { p.velocity = clampVelocity(p.velocity); p.amp = clampAmp(p.amp); @@ -65,18 +63,16 @@ VelocityCurve VelocityCurve::fromPoints(std::vector pts) { [](const VelocityPoint& a, const VelocityPoint& b) { return a.velocity < b.velocity; }); - // Fewer than 2 usable points -> can't span [0,127] as a function; fall back to the flat default. if (pts.size() < 2) return flat(); - // Force endpoints present at velocity 0 and 127 (they must exist for eval to be total). if (pts.front().velocity > kVelMin) { pts.insert(pts.begin(), VelocityPoint{kVelMin, pts.front().amp}); } else { - pts.front().velocity = kVelMin; // snap a near-0 first point exactly onto the endpoint + pts.front().velocity = kVelMin; } if (pts.back().velocity < kVelMax) { pts.push_back(VelocityPoint{kVelMax, pts.back().amp}); } else { - pts.back().velocity = kVelMax; // snap a near-127 last point exactly onto the endpoint + pts.back().velocity = kVelMax; } VelocityCurve c; c.points_ = std::move(pts); @@ -85,19 +81,12 @@ VelocityCurve VelocityCurve::fromPoints(std::vector pts) { namespace { -// Fritsch–Carlson monotone-cubic tangent for one interior knot i, given the secant slopes of the -// two adjacent segments (dPrev = secant into knot i, dNext = secant out of knot i). Returns the -// limited tangent that keeps the cubic Hermite piece monotone and inside the data range. -// -// The rule: a tangent whose adjacent secants have opposite signs (or either is flat) is a local -// extremum — pin the tangent to 0 so the curve does not overshoot past the knot. Otherwise use the -// weighted-harmonic-mean tangent (Fritsch–Carlson eq. 4), which for COLLINEAR knots (dPrev==dNext) -// reduces to that common secant — so collinear control points reproduce the straight line to within -// floating-point rounding (~1e-15), preserving the Option-B / null-response contract for linear(). +// Fritsch-Carlson monotone-cubic tangent: a sign change (or flat) neighbour is a local extremum, +// so the tangent pins to 0 to avoid overshoot; otherwise the weighted-harmonic-mean tangent, +// which for collinear knots (dPrev==dNext) reduces exactly to the shared secant — this is what +// makes the spline reproduce a straight line to ~1e-15 for linear()-style input. double fritschCarlsonTangent(double dPrev, double dNext, double spanPrev, double spanNext) { - if (dPrev * dNext <= 0.0) return 0.0; // sign change or a flat neighbour -> local extremum - // Weighted harmonic mean of the two secants (weights = the two segment widths). Collinear case: - // dPrev==dNext==d makes this (w1+w2)*d / ((w1+w2)/... ) collapse to d exactly. + if (dPrev * dNext <= 0.0) return 0.0; const double w1 = 2.0 * spanNext + spanPrev; const double w2 = spanNext + 2.0 * spanPrev; return (w1 + w2) / (w1 / dPrev + w2 / dNext); @@ -106,33 +95,23 @@ double fritschCarlsonTangent(double dPrev, double dNext, double spanPrev, double } // namespace double VelocityCurve::eval(double velocity) const { - if (points_.empty()) return kAmpMax; // degenerate (shouldn't occur) -> flat unity - if (points_.size() == 1) return clampAmp(points_[0].amp); // 1-point -> that point's amp + if (points_.empty()) return kAmpMax; + if (points_.size() == 1) return clampAmp(points_[0].amp); const double v = clampVelocity(velocity); - // At or before the first point / at or after the last, read the endpoint amp (the endpoints are - // at 0 and 127, so this only fires exactly at the ends for an in-range velocity). if (v <= points_.front().velocity) return clampAmp(points_.front().amp); if (v >= points_.back().velocity) return clampAmp(points_.back().amp); - // Find the segment [points_[i], points_[i+1]] containing v (X-ordered, so a linear scan). for (std::size_t i = 0; i + 1 < points_.size(); ++i) { const VelocityPoint& a = points_[i]; const VelocityPoint& b = points_[i + 1]; if (v >= a.velocity && v <= b.velocity) { const double span = b.velocity - a.velocity; - // Coincident-X neighbours (a step): jump straight to the later point's amp — the segment - // has zero width so there is no interior to blend. + // Coincident-X neighbours (a step): zero-width segment, no interior to blend. if (span <= 0.0) return clampAmp(b.amp); - // --- Monotone cubic Hermite (Fritsch–Carlson) interpolation on segment [a,b] --------- - // Curved (spline) response, not straight lines. The interpolant provably stays within - // [a.amp, b.amp] between the two knots (no bulge below 0 / above 1), and for collinear - // control points its tangents reduce to the secant slope — so it reproduces the straight - // line to within floating-point rounding (~1e-15), preserving linear()'s null-response - // contract (y = velocity/127 to ~1e-15; the test tolerance of 1e-12 is appropriate). - const double d = (b.amp - a.amp) / span; // secant of THIS segment + // Monotone cubic Hermite (Fritsch-Carlson): provably stays within [a.amp, b.amp] + // between the two knots (no overshoot), reproducing a straight line for collinear input. + const double d = (b.amp - a.amp) / span; - // Tangent at a: 0 if a is the first knot (endpoint), else the FC-limited tangent using - // the previous segment's secant. Same for the tangent at b (0 at the last knot). double mA = d; if (i > 0) { const VelocityPoint& prev = points_[i - 1]; @@ -141,7 +120,7 @@ double VelocityCurve::eval(double velocity) const { const double dPrev = (a.amp - prev.amp) / spanPrev; mA = fritschCarlsonTangent(dPrev, d, spanPrev, span); } else { - mA = 0.0; // coincident-X predecessor (a step at a) -> flat tangent + mA = 0.0; } } double mB = d; @@ -152,13 +131,10 @@ double VelocityCurve::eval(double velocity) const { const double dNext = (next.amp - b.amp) / spanNext; mB = fritschCarlsonTangent(d, dNext, span, spanNext); } else { - mB = 0.0; // coincident-X successor (a step at b) -> flat tangent + mB = 0.0; } } - // Cubic Hermite basis on the normalized position t across [a,b]. For collinear knots - // mA==mB==d, so h00*a + (h10*span)*d + h01*b + (h11*span)*d collapses to the straight - // line to within floating-point rounding (~1e-15). const double t = (v - a.velocity) / span; const double t2 = t * t; const double t3 = t2 * t; @@ -175,8 +151,7 @@ double VelocityCurve::eval(double velocity) const { std::size_t VelocityCurve::addPoint(double velocity, double amp) { const VelocityPoint p{clampVelocity(velocity), clampAmp(amp)}; - // Insert keeping X-order: first index whose velocity is STRICTLY greater than the new one, so a - // duplicate-X point lands immediately after the existing one (a later move can separate them). + // First index strictly greater, so a duplicate-X point lands immediately after the existing one. std::size_t i = 0; while (i < points_.size() && points_[i].velocity <= p.velocity) ++i; points_.insert(points_.begin() + static_cast(i), p); @@ -191,11 +166,10 @@ VelocityPoint VelocityCurve::movePoint(std::size_t index, double velocity, doubl double newAmp = clampAmp(amp); double newVel; if (isFirst) { - newVel = kVelMin; // endpoint pinned in X at 0 — only amp moves + newVel = kVelMin; } else if (isLast) { - newVel = kVelMax; // endpoint pinned in X at 127 — only amp moves + newVel = kVelMax; } else { - // Interior point: clamp X strictly within its immediate neighbours so it can't cross them. const double lo = points_[index - 1].velocity; const double hi = points_[index + 1].velocity; newVel = std::clamp(clampVelocity(velocity), lo, hi); @@ -216,9 +190,7 @@ VelocityCurve::CurvePixel VelocityCurve::pixelFromPoint(const Box& box, const Ve } VelocityPoint VelocityCurve::pointFromPixel(const Box& box, int x, int y) { - // The exact inverse of velToX/ampToY (within the one-pixel rounding quantum). Degenerate - // dimensions collapse the same way the forward map does: velToX pins to box.left (velocity 0), - // ampToY pins to box.top (amp 1). + // Exact inverse of velToX/ampToY (within one pixel); degenerate dims collapse the same way. VelocityPoint p; const int w = std::max(0, box.width); const int h = std::max(0, box.height); diff --git a/src/core/instrument/engine/velocity_curve.h b/src/core/instrument/engine/velocity_curve.h index a2de2dc..fddfd77 100644 --- a/src/core/instrument/engine/velocity_curve.h +++ b/src/core/instrument/engine/velocity_curve.h @@ -1,43 +1,13 @@ -// velocity_curve.h — PURE velocity->amp transfer curve (S-VIEW-9, r10). NO VST3, NO REAPER, NO -// SWELL/LICE, NO vendor/ includes at the boundary. The mirror of envelope_edit / card_drag: the -// eval + the clamp/order/inverse-map arithmetic live here, unit-tested outside the DAW; the future -// editor shell (reasampler_editor.cpp, S-VIEW-10) draws the box + node handles and feeds each move's -// pixel delta back through here, committing the result to the zone through the same off-audio-thread -// path a slider edit uses. -// -// WHAT IT IS. A monotonic-in-x transfer function mapping MIDI velocity (X: 0..127) to an amp scalar -// (Y: 0..1), authored as an ordered list of control points. eval(velocity) is called ONCE per -// note-on in Voice::start() (never per frame) to set the voice's velocityGain_, replacing the fixed -// linear velocity/127 map. The curve is a per-PerformanceZone performance characteristic (D-B) — a -// sibling of the AHDSR envelope, pitch engine, and keyTrack scalar — so it varies per sound, stored -// on PerformanceZone and resolved onto the KeyZone at keymap build (mirror of keyTrack). -// -// DEFAULT — flat y=1 (fork R10-F1 Option A, Daniel 2026-07-27). VelocityCurve::flat() is the seeded -// default: EVERY velocity plays at unity amp. This is a DELIBERATE, Daniel-approved behavior change -// vs. the shipped linear velocity/127 map — soft hits are now full level until a curve is drawn. -// NOT bit-identical to the pre-r10 engine, by design; do not "preserve" the linear response. -// -// THE INVARIANT (mirror of envelope_edit's S-VIEW-F2). A drag/edit can NEVER produce a curve eval -// couldn't handle: -// * X-ORDERED — a point clamps between its predecessor's and successor's velocity, so control -// points never cross in X. This is what makes eval a well-defined FUNCTION (one amp per -// velocity): each X falls in exactly one [p_i, p_{i+1}] segment. -// * BOX-CLAMPED — velocity clamps to [0,127], amp clamps to [0,1] (the drawn box). -// Both endpoints (velocity 0 and 127) are always present so eval is total over [0,127]; delete -// refuses to remove them, and the constructors seed them. +// velocity_curve.h — velocity->amp transfer curve. eval(velocity) is called once per note-on +// in Voice::start(), never per frame. Editor hit-test/inverse-map take an explicit pixel Box +// rather than a Rect: this module sits below sampler_core in the link graph and must not gain +// a transitive dependency on editor-layout types. #pragma once #include #include -// DELIBERATELY dependency-free at the boundary (no editor_geometry / Rect). This module sits BELOW -// sampler_core in the link graph (KeyZone carries a VelocityCurve; Voice::start calls eval), and the -// engine must not gain a transitive dependency on the editor's layout types. The editor hit-test / -// inverse-map therefore takes an explicit pixel box (boxLeft/boxTop/boxWidth/boxHeight) rather than a -// Rect — the future editor shell (S-VIEW-10) passes its box coords directly. Mirror of envelope_edit's -// role, but one layer lower, so the coupling stays out of the engine core. - namespace reasampler::instrument::engine { // The MIDI velocity domain [0,127] and the amp range [0,1] — the box every point clamps into. @@ -46,81 +16,56 @@ inline constexpr double kVelMax = 127.0; inline constexpr double kAmpMin = 0.0; inline constexpr double kAmpMax = 1.0; -// One control point: a (velocity, amp) knot the curve passes through. Both fields are box-clamped -// by the mutators; a raw-constructed point is NOT auto-clamped (the mutators own the invariant), so -// build curves through the named constructors / addPoint rather than pushing raw points. +// A raw-constructed point is NOT auto-clamped (the mutators own that invariant) — build curves +// through the named constructors / addPoint rather than pushing raw points. struct VelocityPoint { double velocity = 0.0; // X, [0,127] double amp = 0.0; // Y, [0,1] }; -// The pick radius (px) around a node's drawn point for the editor hit-test. Mirrors -// envelope_edit::kNodeGrabRadius / waveform_view::kMarkerGrabWidth. +// Pick radius (px) around a node's drawn point for the editor hit-test. inline constexpr int kCurveNodeGrabRadius = 6; -// A velocity->amp transfer curve: an X-ORDERED list of control points spanning [0,127], evaluated by -// a MONOTONE cubic Hermite spline (Fritsch–Carlson slope limiting) through the knots — a genuine -// curved response (Daniel 2026-07-27: "straight lines sound like shit"), not a polyline. Each -// velocity still maps to exactly one amp: the interpolant is single-valued and provably stays within -// each segment's amp range, so the curve never overshoots below 0 or above 1. For COLLINEAR knots the -// Fritsch–Carlson tangents reduce to the secant slope, so the spline reproduces the straight line to -// within floating-point rounding (~1e-15) — that preserves linear()'s null-response contract -// (y = velocity/127 to ~1e-15; the 1e-12 test tolerance is deliberately conservative). The two endpoints -// (velocity 0 and 127) are load-bearing: they keep eval total and are never deletable. +// An X-ordered list of control points spanning [0,127], evaluated by a monotone cubic Hermite +// spline (Fritsch-Carlson slope limiting) — a genuine curve, not a polyline, that provably never +// overshoots a segment's amp range. For collinear knots the tangents reduce to the secant slope, +// so the spline reproduces linear()'s straight line to within ~1e-15. The two endpoints (velocity +// 0 and 127) are load-bearing: they keep eval total over the domain and are never deletable. class VelocityCurve { public: - // R10-F1 default (Option A): flat y=1 — endpoints (0,1) and (127,1); every velocity -> unity. + // flat() (endpoints (0,1)/(127,1), every velocity -> unity) is the default — see + // velocity_curve in the directory CLAUDE.md for why this isn't bit-identical to the + // pre-existing linear() response. static VelocityCurve flat(); - // The classic linear ramp y = velocity/127 — endpoints (0,0) and (127,1). Retained for tests - // and as the Option-B seed; NOT the default (see R10-F1). static VelocityCurve linear(); - // Rebuild a curve from a deserialized point list, REPAIRING the invariant defensively (the - // deserialization seam, sample_map's zones-payload v7). Each point is box-clamped; the list is - // stable-sorted by velocity (X-ordered); endpoints at velocity 0 and 127 are forced present - // (an absent endpoint is synthesized at the nearest interior amp, or unity for an empty list). - // A list with fewer than 2 usable points falls back to flat(). Never trusts the wire blindly — - // a corrupt/truncated blob yields a well-formed curve, never an invariant-violating one. + // Rebuilds from a deserialized point list, repairing the invariant defensively: box-clamps + // each point, stable-sorts by velocity, forces both endpoints present (synthesized if + // missing), falls back to flat() if fewer than 2 usable points remain. A corrupt/truncated + // blob yields a well-formed curve, never an invariant-violating one. static VelocityCurve fromPoints(std::vector pts); - // The control points, X-ordered, first at velocity 0 and last at velocity 127 (invariant). const std::vector& points() const { return points_; } std::size_t size() const { return points_.size(); } - // Evaluate the curve at `velocity` -> amp in [0,1]. Velocity is box-clamped to [0,127] first, - // so an out-of-range note (shouldn't occur) reads the nearest endpoint. Between two adjacent - // points the amp follows a MONOTONE cubic Hermite spline (Fritsch–Carlson slope limiting) — a - // true curve that provably stays within the two knots' amp range (no overshoot below 0 / above - // 1) and reproduces the straight line to within floating-point rounding (~1e-15) for collinear - // knots. Single-valued / monotonic in X. - // Degenerate cases (shouldn't occur post-construction): an EMPTY curve returns kAmpMax (flat - // unity); a ONE-point curve returns that point's amp. + // Degenerate cases (shouldn't occur post-construction): empty curve returns kAmpMax; a + // one-point curve returns that point's amp. double eval(double velocity) const; - // --- Editing (for the S-VIEW-10 editor UI) -------------------------------------------------- - // Insert a new control point, box-clamped, keeping the list X-ordered by velocity. Returns the - // index of the inserted point. A new point at a velocity that duplicates an existing one is - // inserted immediately AFTER it (so a subsequent move can separate them); the endpoints are not - // special-cased on insert (a point at exactly 0 or 127 inserts adjacent to that endpoint). + // Inserted at a velocity duplicating an existing point lands immediately after it, so a + // subsequent move can separate them. Returns the inserted index. std::size_t addPoint(double velocity, double amp); - // Move point `index` to (velocity, amp), box-clamped AND X-clamped between its immediate - // neighbours so it cannot cross them (monotonic-X grammar). The two ENDPOINTS are pinned in X - // (index 0 stays at velocity 0, the last stays at 127) — only their AMP moves; their velocity - // argument is ignored. An out-of-range index is a no-op. Returns the (possibly clamped) - // resulting point. + // Box-clamped and X-clamped between immediate neighbours (monotonic-X grammar). The two + // endpoints are pinned in X (only their amp moves); out-of-range index is a no-op. VelocityPoint movePoint(std::size_t index, double velocity, double amp); - // Delete point `index`. The two endpoints (index 0 and the last) are NOT deletable — a request - // to remove either, or an out-of-range index, is a no-op returning false. Returns true iff a - // point was removed. + // Endpoints (index 0 and last) are not deletable; that or an out-of-range index is a no-op + // returning false. bool deletePoint(std::size_t index); - // --- Editor hit-test + inverse map (mirror of envelope_edit) -------------------------------- - // The drawn box, in pixels: origin (boxLeft, boxTop), `boxWidth` px wide, `boxHeight` px tall. - // X = velocity across the width (0 at boxLeft, 127 at boxLeft+boxWidth); Y = amp UP the height - // (amp 1 at boxTop, amp 0 at boxTop+boxHeight-1). Passed explicitly (not a Rect) so this module - // stays free of editor-layout types — see the header preamble. + // The drawn box, in pixels: X = velocity across the width, Y = amp UP the height (amp 1 at + // top). Passed explicitly rather than a Rect — see header preamble. struct Box { int left = 0; int top = 0; @@ -128,43 +73,32 @@ public: int height = 0; }; - // Which control point a grab at (x,y) lands on, given the drawn `box`. Returns the index of the - // first point within the pick radius in BOTH axes, or -1 for a miss. First-match in point order - // for determinism (mirror of nodeAtPoint). + // Index of the first point within the pick radius on both axes, or -1 for a miss. First-match + // in point order for determinism. int pointAtPixel(const Box& box, int x, int y) const; - // A node's drawn pixel position (S-VIEW-10). The ONE point->pixel mapping — the same mapping - // pointAtPixel hit-tests against — exposed so the editor shell draws the trace + node handles - // at exactly the coordinates the hit-test expects (draw and grab can never drift). + // The one point->pixel mapping, exposed so drawing and hit-testing can never drift apart. struct CurvePixel { int x = 0; int y = 0; }; static CurvePixel pixelFromPoint(const Box& box, const VelocityPoint& p); - // The absolute pixel -> (velocity, amp) inverse (S-VIEW-10): where an empty-space click lands - // as a NEW control point, box-clamped. The exact inverse of pixelFromPoint's mapping (within - // the one-pixel quantum), so an added point appears under the cursor. Degenerate box: a - // zero-width box reads velocity 0; a height <= 1 box reads amp 1 (the top row), mirroring - // pixelFromPoint's degenerate collapse. + // Exact inverse of pixelFromPoint (within the one-pixel quantum) — where an empty-space click + // lands as a new point. Degenerate box: zero-width reads velocity 0; height <= 1 reads amp 1. static VelocityPoint pointFromPixel(const Box& box, int x, int y); - // Resolve a drag of point `index` by a pixel delta since grab, given the curve AS OF GRAB TIME - // (`grabCurve` — the shell snapshots it on mouse-down so the delta is absolute) and the box. - // Maps the pixel delta to a (velocity, amp) delta over the box, then applies movePoint's clamp - // (box + neighbour X + endpoint X-pin). A zero-width/height box or out-of-range index returns - // `grabCurve` unchanged. Pure — mirror of resolveNodeDrag. + // `grabCurve` is the curve as of mouse-down (shell snapshots it so the delta is absolute). + // Maps the pixel delta to velocity/amp over the box, then applies movePoint's clamp. Zero + // width/height box or out-of-range index returns grabCurve unchanged. static VelocityCurve resolvePointDrag(const VelocityCurve& grabCurve, std::size_t index, const Box& box, int dxPixels, int dyPixels); - // Equality (for tests + round-trip assertions): same point count + each point equal within a - // tight epsilon. bool equals(const VelocityCurve& other, double eps = 1e-9) const; private: - // Points are always X-ordered with an endpoint at 0 and 127. Constructed only through the named - // constructors + deserialize (see sample_map), which establish that invariant; the mutators - // preserve it. + // Always X-ordered with an endpoint at 0 and 127; constructors + deserialize establish the + // invariant, mutators preserve it. std::vector points_; }; diff --git a/src/core/instrument/engine/zone_params.h b/src/core/instrument/engine/zone_params.h index 387375e..6e3f257 100644 --- a/src/core/instrument/engine/zone_params.h +++ b/src/core/instrument/engine/zone_params.h @@ -1,122 +1,88 @@ #pragma once -// zone_params.h — the per-zone play-parameter VALUE STRUCTS + per-instance mode enums the -// sampler engine, the sample_map resolution layer, the ComponentState codec, and the editor -// all share (Q-W2v header split, T4-14/T4-17). Split out of sampler_core.h so a UI or codec -// TU that reads a param struct no longer recompiles when a Voice/VoiceEngine member changes. -// PURE: NO VST3, NO REAPER, NO SWELL, NO vendor/ includes — standard library + peaks only. -// The per-frame EVALUATOR classes (AdsrEnvelope / TriggerEnvelope / PitchEnvelope) and the -// engine (Keymap / Voice / VoiceEngine) stay in sampler_core.h. +// zone_params.h — per-zone play-parameter value structs + per-instance mode enums shared by +// the engine, sample_map, the ComponentState codec, and the editor. Split out of sampler_core.h +// so a UI/codec TU reading a param struct doesn't recompile when a Voice/VoiceEngine member +// changes. The per-frame evaluator classes (AdsrEnvelope/TriggerEnvelope/PitchEnvelope) and the +// engine (Keymap/Voice/VoiceEngine) stay in sampler_core.h. #include #include -#include "core/audio/peaks.h" // AudioSample (float) +#include "core/audio/peaks.h" namespace reasampler { -// Q-W1 interim: the flat `reasampler` namespace is the engine family's home until its own -// re-namespace lands; the deps live in their sub-namespace homes. using audio::AudioSample; -// The instrument's per-instance output channel mode (S7, D-E). MONO keeps the pre-S7 -// downmix path (one channel out); STEREO negotiates a 2-channel output bus and renders -// per-channel. A PERFORMANCE choice the instrument owns (component state), never written -// to the bank. Default Mono preserves current behavior. Lives in the pure core as a plain -// value so the shell (bus negotiation, state) and the engine share one spelling; the core -// itself never branches on it — the mode only picks which render overload the shell drives. +// Decode-side downmix policy (see root CLAUDE.md — the output bus itself is permanently +// stereo; this only picks mono-downmix vs dual-mono at decode). Never written to the bank. enum class ChannelMode { Mono, Stereo }; -// The instrument's per-instance VOICE MODE (Phase S voice redesign). POLY is today's -// polyphonic engine (fixed pool + bounded stealing); MONO is a single voice with LAST-NOTE -// priority over a held-note stack (classic mono synth: a new note takes the voice over; the -// release of the top note falls back to the most-recent still-held note). A PERFORMANCE -// choice the instrument owns (component state), never a bank fact. Default Poly preserves -// current behavior. +// POLY is the fixed-pool engine with bounded stealing; MONO is a single voice with last-note +// priority over a held-note stack (a new note takes over; releasing the top note falls back to +// the most-recent still-held one). Never a bank fact. Default Poly. enum class VoiceMode { Poly, Mono }; -// How a MONO takeover treats the envelopes (Phase S — Daniel: explicitly toggleable). -// RETRIGGER restarts the amplitude (and pitch) envelope on every new mono note. LEGATO keeps -// the envelope running when a note is taken over while another is held — pitch moves without -// a re-attack (and the fallback on top-note release glides back the same way). Legato applies -// only to a SAME-SAMPLE takeover: crossing into a zone playing a different sample restarts -// the voice (one read head cannot glide between two PCM streams; a re-attack on a sample -// change is the deterministic, documented fallback). Meaningless in Poly. Default Retrigger. +// How a MONO takeover treats the envelopes. RETRIGGER restarts amp/pitch envelopes on every new +// mono note. LEGATO keeps the envelope running across a takeover (pitch moves without a +// re-attack) but only for a SAME-SAMPLE takeover — one read head can't glide between two PCM +// streams, so crossing into a different sample always restarts the voice. Meaningless in Poly. enum class MonoTrigger { Retrigger, Legato }; -// The user-parameterized polyphony bound (Phase S): a per-instance persisted voice count. -// One spelling shared by the engine, the component-state (de)serializer, and the editor's -// control so the range can never drift apart. Default 16 == the pre-Phase-S fixed pool. +// Shared range so the engine, the component-state codec, and the editor control can't drift. inline constexpr int kMinVoiceCount = 1; inline constexpr int kMaxVoiceCount = 32; inline constexpr int kDefaultVoiceCount = 16; -// --------------------------------------------------------------------------- -// S15/S16 per-zone play PARAMETERS (plain data). Defined up here (before SampleData) because -// SampleData carries a ZonePlayParams by value — a voice reads it at start(). The matching -// per-frame EVALUATOR classes (AHDSR AdsrEnvelope, TriggerEnvelope, PitchEnvelope) live lower -// with the rest of the engine machinery; only the value structs need to precede SampleData. -// --------------------------------------------------------------------------- - -// AHDSR amplitude envelope parameters (S15 grows the S3 ADSR with a HOLD stage between Attack -// and Decay). holdFrames == 0 is EXACTLY the pre-S15 ADSR (back-compat). See AdsrEnvelope below. +// AHDSR amplitude envelope. holdFrames == 0 is exactly the pre-hold-stage ADSR (back-compat). struct AdsrParams { std::int64_t attackFrames = 0; - std::int64_t holdFrames = 0; // S15: hold at 1.0 between Attack and Decay; 0 = pre-S15 ADSR + std::int64_t holdFrames = 0; std::int64_t decayFrames = 0; double sustainLevel = 1.0; // 0..1 std::int64_t releaseFrames = 0; }; -// S15 play mode. GATE = classic held note (AHDSR + sustain loop + note-off release, today's -// behavior grown by the hold stage). TRIGGER = one-shot: note-off-immune, no sustain loop, -// plays a % of the sample length shaped by fade-in/out. Both honor the start point. Per-zone -// (D-B); DEFAULT Gate so an instrument with no S15 params plays exactly as before. +// GATE = classic held note (AHDSR + sustain loop + note-off release). TRIGGER = one-shot: +// note-off-immune, no sustain loop, plays a % of sample length shaped by fade-in/out. Both +// honor the start point. Per-zone; default Gate so an instrument with no params set plays +// exactly as before. enum class PlayMode { Gate, Trigger }; -// Trigger amplitude envelope parameters (S15). Playback covers the source-frame span -// [startFrame, playEnd), playEnd = startFrame + round(lengthFraction*(frames - startFrame)), -// lengthFraction in (0,1]. Amplitude ramps 0->1 over fadeInFrames at the head and 1->0 over -// fadeOutFrames anchored to playEnd; unity between. Fades clamp so fadeIn + fadeOut <= play -// length. The voice frees when the head reaches playEnd. Note-off is a no-op in Trigger. +// Playback covers [startFrame, playEnd), playEnd = startFrame + +// round(lengthFraction*(frames - startFrame)). Amplitude ramps 0->1 over fadeInFrames at the +// head and 1->0 over fadeOutFrames anchored to playEnd; unity between. Fades clamp so +// fadeIn + fadeOut <= play length. The voice frees when the head reaches playEnd. struct TriggerParams { double lengthFraction = 1.0; // (0,1] of the post-start span to play - std::int64_t fadeInFrames = 0; // 0->1 ramp at the head - std::int64_t fadeOutFrames = 0; // 1->0 ramp anchored to playEnd + std::int64_t fadeInFrames = 0; + std::int64_t fadeOutFrames = 0; }; -// The fade curve for Trigger's ramps. EQUAL_POWER (constant-power sin/cos) is the default -// (click-free on one-shots, per spec); LINEAR is the build-time residual. An enum (not a bool) -// so a third curve can join without a signature change. +// EQUAL_POWER (constant-power sin/cos) is the click-free default for Trigger's ramps; LINEAR is +// the build-time residual. enum class FadeCurve { EqualPower, Linear }; - -// The DEFAULT fade curve (S15 spec: equal-power). One constant to flip if linear is wanted. inline constexpr FadeCurve kDefaultFadeCurve = FadeCurve::EqualPower; -// The per-zone pitch engine. VARISPEED = today's path (readPos_ += ratio_): pitch and duration -// coupled (an octave up plays half as long). PRESERVE = duration-preserving: the read advances -// at the SOURCE rate while a PitchShifter transposes the output (an octave up keeps its length). +// VARISPEED: readPos_ += ratio_, pitch and duration coupled (an octave up plays half as long). +// PRESERVE: the read advances at the source rate while a PitchShifter transposes the output +// (an octave up keeps its length). enum class PitchEngine { Varispeed, Preserve }; -// The PRODUCT DEFAULT pitch engine (S16-F1 — Daniel's "I want duration-preserving repitching" -// directive). ONE constant to flip if Varispeed should be the default instead. This is the -// default a NEW or absent-in-the-blob zone gets — APPLIED AT THE STATE BOUNDARY (sample_map's -// deserialize / editor zone-creation), NOT the pure-core struct default. The pure-core -// ZonePlayParams.pitchEngine member defaults to VARISPEED so that "no params == the pre-S16 -// engine" holds for the core's own regression tests (an octave up still halves duration in the -// bare engine); the Preserve product default is layered on above at (de)serialization. +// Product default is Preserve, but applied at the state boundary (sample_map deserialize / +// editor zone-creation) for new/absent zones, NOT here: ZonePlayParams.pitchEngine itself +// defaults to Varispeed so "no params == the bare engine" holds for the core's own regression +// tests (an octave up still halves duration with no params set). inline constexpr PitchEngine kDefaultPitchEngine = PitchEngine::Preserve; -// The OLA window (frames) the Preserve PitchShifter uses, derived from a window in milliseconds -// at the voice's sample rate. ~50 ms is the WDL quality-0 window the spec cites; larger = -// smoother on big transpositions. Onset latency is ZERO: start() primes the ring with the first -// window of real source, so output frame 0 IS source frame 0 regardless of window size (GA2 fix). -// One knob, resolved at voice allocation. +// OLA window for the Preserve PitchShifter, in ms at the voice's sample rate; larger = smoother +// on big transpositions. Onset latency is zero — start() primes the ring with the first window +// of real source, so output frame 0 is source frame 0 regardless of window size. inline constexpr double kPreserveWindowMs = 50.0; -// A per-voice AD pitch-modulation envelope (S16), OFF by default (enabled=false -> offset always -// 0 -> playback bit-identical to the un-modulated engine). At note-on the pitch offset rises to -// peakSemitones over attackFrames, then falls to 0 (base pitch) over decayFrames. A zero attack -// gives the pure "start high, drop to base" percussive drop. peakSemitones is signed (+/-). +// AD pitch-modulation envelope, off by default (enabled=false -> offset always 0 -> bit-identical +// to the un-modulated engine). At note-on the offset rises to peakSemitones over attackFrames, +// then falls to 0 over decayFrames; a zero attack gives a pure percussive pitch drop. struct PitchEnvParams { bool enabled = false; std::int64_t attackFrames = 0; @@ -124,69 +90,53 @@ struct PitchEnvParams { double peakSemitones = 0.0; // signed depth at the peak }; -// The bundle of S15/S16 per-zone play parameters a voice reads at start(). Lives on SampleData -// (each zone owns one SampleData in the zoned keymap). DEFAULTS are EXACTLY the pre-S15/S16 -// engine: Gate mode, AHDSR with hold 0 (= the S3 ADSR), VARISPEED pitch engine, pitch envelope -// disabled — so a bare-core voice with default play is byte-identical to the pre-S15 build (the -// core regression tests rely on this). The PRODUCT default of Preserve (S16-F1) is applied one -// layer up at (de)serialization for new/absent zones — see kDefaultPitchEngine. +// Bundle a voice reads at start(). Defaults reproduce the bare engine (Gate, hold-0 AHDSR, +// Varispeed, pitch envelope off) — core regression tests rely on this; the Preserve product +// default is layered on at (de)serialization, see kDefaultPitchEngine. struct ZonePlayParams { PlayMode playMode = PlayMode::Gate; - AdsrParams adsr; // Gate: the AHDSR envelope - TriggerParams trigger; // Trigger: %-length + fades + AdsrParams adsr; + TriggerParams trigger; PitchEngine pitchEngine = PitchEngine::Varispeed; - PitchEnvParams pitchEnv; // AD pitch modulation, off by default + PitchEnvParams pitchEnv; }; -// --------------------------------------------------------------------------- -// Sample data the core plays. Plain, decoded PCM + the S2 bank intrinsics that -// govern playback. The shell decodes the on-disk WAV and fills this; the core -// never touches a file. -// --------------------------------------------------------------------------- +// Sample data the core plays: plain decoded PCM + the bank intrinsics that govern playback. +// The shell decodes the on-disk WAV and fills this; the core never touches a file. -// A loop over [start, end) frames, half-open. A zero-length loop (start == end) -// is the "no sustain loop" marker — a held note past the sample end goes silent -// rather than looping a zero span. absent-loop is modeled by leaving hasLoop false. +// [start, end) frames, half-open. A zero-length loop (start == end) is the "no sustain loop" +// marker — a held note past the sample end goes silent rather than looping a zero span. struct SampleLoop { bool hasLoop = false; - std::int64_t start = 0; // first looped frame (inclusive) - std::int64_t end = 0; // one-past-last looped frame (exclusive); start <= end + std::int64_t start = 0; + std::int64_t end = 0; }; -// One decoded audio sample the engine can voice. DEINTERLEAVED, per-channel: `frames` is -// channel 0 (always present) and `framesR` is channel 1 (present only for a STEREO sample). -// A sample is stereo iff `framesR` is non-empty AND the same length as `frames`; otherwise -// it is mono (the degenerate, byte-identical Tier 0-1 case — `framesR` stays empty). Both -// channels share `readPos_`, `rootNote`, and `loop`, so repitch/loop are per-frame identical -// across channels; only the sampled value differs. `rootNote` is the MIDI note the file was -// recorded at (S2 intrinsic) — the pitch that plays back at unity ratio. +// Deinterleaved per-channel: `frames` is channel 0 (always present), `framesR` is channel 1 +// (present only for a stereo sample). Stereo iff `framesR` is non-empty and the same length as +// `frames`; a mismatched length is treated as absent (mono) rather than half-playing. Both +// channels share `readPos_`/`rootNote`/`loop`, so repitch/loop stay per-frame identical across +// channels. `rootNote` is the MIDI note the file was recorded at — plays at unity ratio there. struct SampleData { - std::vector frames; // channel 0 PCM (mono, or L of a stereo sample) - std::vector framesR; // channel 1 PCM (R); EMPTY for a mono sample - int sampleRate = 0; // frames per second (for reference; ratio is - // note-relative, so rate cancels for repitch). - // 0 is explicitly invalid — every consumer must - // receive a real rate before use. - int rootNote = 60; // MIDI note recorded at (plays at unity here) - SampleLoop loop; // sustain loop, if any - // Initial read position (frame offset) a voice starts playback at — frame 0 by - // default, so an unset start point is exactly the pre-S11 behavior. S11 makes this - // an instrument-side per-zone override (the "start point" marker); S15 builds on it - // (both play modes carry a modifiable start). Clamped into [0, frames) at note-on: - // a start >= the sample length is a no-op (voice starts at 0), never out of bounds. + std::vector frames; + std::vector framesR; // empty for a mono sample + int sampleRate = 0; // ratio math is note-relative, so rate cancels for + // repitch; still, 0 is invalid — every consumer must + // receive a real rate before use. + int rootNote = 60; + SampleLoop loop; + + // Frame offset a voice starts playback at; frame 0 default is the pre-existing behavior. + // Clamped into [0, frames) at note-on — a start >= sample length is a no-op (starts at 0). std::int64_t startFrame = 0; - // S15/S16 per-zone play parameters (play mode, AHDSR/Trigger envelope, pitch engine, pitch - // envelope). Defaults reproduce the pre-S15 engine EXCEPT the pitch engine default is - // Preserve (S16-F1). A voice reads this at start(). Struct defined above SampleData. ZonePlayParams play; - // 2 iff a matching-length second channel exists; else 1. A framesR of a different - // length than frames is treated as absent (mono) — a malformed pair never half-plays. + // A framesR of a different length than frames is treated as absent — a malformed pair + // never half-plays. int channelCount() const { return (!framesR.empty() && framesR.size() == frames.size()) ? 2 : 1; } - }; } // namespace reasampler diff --git a/src/core/instrument/map/bank_sync.cpp b/src/core/instrument/map/bank_sync.cpp index 10532e9..356bb29 100644 --- a/src/core/instrument/map/bank_sync.cpp +++ b/src/core/instrument/map/bank_sync.cpp @@ -10,20 +10,14 @@ namespace reasampler::instrument::map { std::int64_t parseBankGeneration(const std::string& raw) { - // Whole-string, non-negative decimal parse WITHOUT exceptions or locale - // surprises — the shared core/wire accumulate (Q-W1, T2-01b). A leading - // '+' / '-', any non-digit, an empty string, or overflow past int64 max all - // reject to the absent default (0); the guarded accumulate means a - // pathologically long digit run can never wrap into a bogus small value. + // Whole-string, non-negative decimal parse, no exceptions/locale surprises (core/wire's + // guarded accumulate). Leading sign, non-digit, empty, or int64 overflow -> absent (0). std::int64_t value = 0; if (!wire::parseUnsignedDecimal(raw, value)) return kBankGenerationAbsent; return value; } std::string formatBankGeneration(std::int64_t generation) { - // Non-negative decimal; a negative (should never be produced by the writer) formats as - // its std::to_string form and would parse back to 0, so the writer's monotonic counter - // stays in the >= 0 domain by construction. return std::to_string(generation); } @@ -37,23 +31,20 @@ AssignConsumeDecision consumeDecision(const std::optional& re AssignConsumeDecision d; d.consumedGeneration = lastConsumed; // default: nothing changes - // Rule 1: no request, or not newer than what we already consumed -> nothing new. + // Rule 1: no request, or not newer than what we already consumed. if (!request) return d; if (request->generation <= lastConsumed) return d; - // Rule 2: a new request, but this instance is not the target -> do not act, do NOT - // advance the marker (stay eligible if focus later lands here). No thundering herd. + // Rule 2: new but not our target -> don't advance the marker, stay eligible. if (!isFocusedTarget) return d; - // The request is new AND we are the target: it will be consumed-as-seen either way, so - // advance the marker to its generation so it is never re-evaluated. + // New and our target: consumed-as-seen either way. d.consumedGeneration = request->generation; - // Rule 3: unresolvable (bankId, sampleId) -> DROP silently (reader requirement): marker - // advanced above, but no selection change. + // Rule 3: unresolvable -> drop silently, marker already advanced above. if (!resolves) return d; - // Rule 4: new, target, resolvable -> apply the selection. + // Rule 4: new, target, resolvable -> apply. d.apply = true; d.bankId = request->bankId; d.sampleId = request->sampleId; diff --git a/src/core/instrument/map/bank_sync.h b/src/core/instrument/map/bank_sync.h index 856bfe5..a85ae8e 100644 --- a/src/core/instrument/map/bank_sync.h +++ b/src/core/instrument/map/bank_sync.h @@ -1,21 +1,11 @@ #pragma once -// bank_sync — PURE decision logic for the S9 bank-generation change-detection and the -// S8 instrument-side assignment-request consume. NO VST3, NO REAPER, NO SWELL, NO -// vendor/ includes. Standard library only. Unit-tested outside the DAW — the mirror of -// sample_map / bridge_marshal splitting the fiddly, testable arithmetic out of a -// host-facing shell. -// -// WHY IT EXISTS (S9/S8 reader seams). The instrument polls two "reasampler" ext-state -// keys off the audio thread: the S9 bank-generation counter (has the bank changed?) and -// the S8 assignment request (should I switch to a just-ingested sample?). The RAW string -// read crosses the bridge in the shell; every DECISION after — parse the generation -// stamp, decide whether it differs from what we last saw, decide whether a decoded -// assignment request is NEW-and-resolvable-and-worth-applying — is pure and lives here. -// -// The processor shell owns the cadence (a UI-thread timer, NEVER process) and the side -// effects (reloadInstrument, setSelectedSampleId); this module owns only the yes/no maths so -// the reader's rules are provable without a host. assignment_request.h owns the WIRE format -// (encode/decode); this module owns the CONSUME decision layered over a decoded request. +// bank_sync — decision logic for bank-generation change-detection and the instrument-side +// assignment-request consume. The instrument polls two "reasampler" ext-state keys off the +// audio thread: the bank-generation counter (has the bank changed?) and the assignment +// request (should I switch to a just-ingested sample?). The shell reads the raw strings and +// owns cadence (a UI-thread timer, never `process`) + side effects (reloadInstrument, +// setSelectedSampleId); this module owns only the yes/no decisions, so they're provable +// without a host. assignment_request.h owns the wire format; this owns the consume decision. #include #include @@ -27,41 +17,26 @@ namespace reasampler::instrument::map { using wire::AssignmentRequest; -// The S9 bank-generation "generation 0 = never stamped" default. A project saved before -// S9 shipped carries no bank_generation key; the bridge read yields an absent/empty value -// which parses to this, and the first real bump (>= 1) then reads as a change. Matches the -// writer's monotonic-from-1 counter (the extension bumps to 1 on the first mutation). +// Default for a project with no bank_generation key yet (pre-existing project); the first +// real bump (>= 1) then reads as a change against this. inline constexpr std::int64_t kBankGenerationAbsent = 0; -// Parse the raw bank-generation ext-state value the bridge read. The writer stamps a -// non-negative decimal integer (formatBankGeneration). Absent / empty / malformed / negative -// / overflowing all yield kBankGenerationAbsent (0) — the reader treats any unreadable stamp -// as "generation 0", so a pre-S9 or corrupt value is a clean default, never a crash and never -// a spurious reload storm (0 vs a previously-seen 0 is no change). Whole-string parse: trailing -// garbage after the digits rejects the value (returns 0), so a torn/partial write is ignored -// until the next clean poll (the read tolerates staleness by design — it reloads on the NEXT -// poll once the value is clean). +// Absent / empty / malformed / negative / overflowing all yield kBankGenerationAbsent (0), +// never a crash or spurious reload. Whole-string parse: trailing garbage rejects the value, +// so a torn/partial write is ignored until the next clean poll. std::int64_t parseBankGeneration(const std::string& raw); -// Format a bank-generation counter for the ext-state stamp. The inverse of -// parseBankGeneration for a non-negative value: a plain decimal, no sign, no padding, so -// the stamp is byte-stable across writes of the same value. +// Inverse of parseBankGeneration: plain decimal, no sign, no padding — byte-stable across +// writes of the same value. std::string formatBankGeneration(std::int64_t generation); -// Has the bank generation changed since the reader last saw `seen`? True when `current` -// differs from `seen` — the reader then triggers a reload. Any difference counts (not just -// an increase): the writer is monotonic, but a project switch or reload can legitimately -// lower the value, and the reader should re-read the bank in that case too. `seen` starts at -// kBankGenerationAbsent so the first non-zero generation reads as a change (the pre-S9 / -// first-bump refresh the spec requires). +// True when `current` differs from `seen` (not just increases — a project switch/reload can +// legitimately lower the value, and the reader should still re-read the bank). bool bankGenerationChanged(std::int64_t seen, std::int64_t current); -// The verdict of the S8 assignment-request consume decision (below). A pure value the -// processor shell acts on: apply the selection (or not) and advance the consumed marker -// (or not). Distinct booleans because the two are NOT the same event — a request may be -// consumed-as-seen (marker advances) without being applied (it named an unresolvable -// sample and was DROPPED per the reader requirement), so the shell must not re-evaluate it -// every poll. +// Verdict of the assignment-request consume decision below. apply and consumedGeneration +// advancing are NOT the same event — a request naming an unresolvable sample is dropped +// (consumed-as-seen) without applying, so the shell doesn't re-evaluate it every poll. struct AssignConsumeDecision { bool apply = false; // set this instance's selection to (bankId, sampleId) + reload std::string bankId; // the request's bank (valid only when apply) @@ -69,37 +44,15 @@ struct AssignConsumeDecision { std::int64_t consumedGeneration = 0; // the marker to persist (== lastConsumed when nothing new) }; -// Decide whether to CONSUME a decoded assignment request (S8 instrument-side reader). +// `lastConsumed` persists across reopen so a request already applied and manually changed +// away from is not reapplied. `resolves` is whether (bankId, sampleId) exists in the live +// bank right now. `isFocusedTarget` gates thundering-herd (only the focused-editor instance +// applies; others neither apply nor advance their marker, staying eligible if focus moves). // -// `request` — the decoded assignment request (nullopt when the assign_request key -// is absent / malformed — nothing pending). -// `lastConsumed` — the generation this instance last consumed (persisted in component -// state so a re-open does not re-apply a request the user already got, -// then manually changed away from). Defaults to 0 for a fresh instance. -// `resolves` — whether the request's (bankId, sampleId) resolves to an existing bank -// sample RIGHT NOW (the shell computed this against the live bank blob). -// `isFocusedTarget` — whether THIS instance is the assignment target under the shell's -// thundering-herd policy (e.g. only the focused-editor instance applies). -// The shell passes true when this instance should act; false suppresses -// consumption entirely so a non-target instance neither applies nor -// advances its marker (it stays eligible if it later becomes the target). -// -// RULES (all pure, order matters): -// 1. No request, or an OLDER/equal generation (<= lastConsumed): nothing new — do not -// apply, marker unchanged. (Covers the re-open case: the persisted marker == the -// request's generation, so it is not re-applied.) -// 2. A NEW request (generation > lastConsumed) but NOT this instance's target: do not -// apply and do NOT advance the marker — a non-target instance must stay able to consume -// the request if focus later lands on it. (No thundering herd: only the target acts.) -// 3. A NEW request, this instance IS the target, but the (bankId, sampleId) does NOT -// resolve: DROP it silently (assignment_request.h reader requirement) — do not apply, -// but DO advance the marker to the request's generation so a stale/unresolvable request -// is consumed-as-seen and never re-evaluated (no error state, no selection change). -// 4. A NEW request, target, and resolvable: APPLY (selection <- (bankId, sampleId)) and -// advance the marker to the request's generation. -// -// The shell then: if apply, setSelectedSampleId + reloadInstrument; always persist -// consumedGeneration into component state when it advanced. +// Rules, in order: (1) no request or generation <= lastConsumed -> no-op. (2) new but not +// the target -> no-op, marker unchanged (stays eligible later). (3) new, target, but doesn't +// resolve -> drop silently, marker still advances (consumed-as-seen, never re-evaluated). +// (4) new, target, resolves -> apply + advance marker. AssignConsumeDecision consumeDecision(const std::optional& request, std::int64_t lastConsumed, bool resolves, bool isFocusedTarget); diff --git a/src/core/instrument/map/bridge_marshal.cpp b/src/core/instrument/map/bridge_marshal.cpp index 2163b55..63cf48b 100644 --- a/src/core/instrument/map/bridge_marshal.cpp +++ b/src/core/instrument/map/bridge_marshal.cpp @@ -6,9 +6,8 @@ namespace reasampler::instrument::map { std::optional decodeGetProjExtState(int apiReturn, const std::string& buffer) { - // REAPER returns the length of the stored value; 0 means the key is absent. Guard - // both the return AND the buffer: a caller that reused a dirty buffer must not - // surface stale bytes as a value when the API reported nothing. + // 0 return means absent; guard the buffer too so a reused dirty buffer can't + // surface stale bytes as a value. if (apiReturn <= 0 || buffer.empty()) return std::nullopt; return buffer; } diff --git a/src/core/instrument/map/bridge_marshal.h b/src/core/instrument/map/bridge_marshal.h index 53a003c..c39bfb9 100644 --- a/src/core/instrument/map/bridge_marshal.h +++ b/src/core/instrument/map/bridge_marshal.h @@ -1,21 +1,8 @@ -// bridge_marshal.h — PURE marshalling helper for the REAPER VST-host bridge read. -// NO VST3, NO REAPER types at the boundary. -// -// The bridge shell (reaper_bridge.cpp) resolves REAPER API functions by name over the -// host callback and invokes them; the one fiddly-and-easy-to-get-wrong part around -// GetProjExtState — interpreting its int return against the buffer it filled — is pure -// and unit-tested here. Mirror of capture_paths / wav_codec splitting the arithmetic out -// of a REAPER-facing shell. -// -// The S1 spike ALSO carried a string-scan JSON reader (extractJsonStringField) as a -// stand-in until the instrument could parse the bank properly. S4 retired it: the -// instrument now parses the "reasampler" bank blob through the SHARED bank_book / -// bank_model JSON path (sample_map.cpp), so there is no second JSON parser. This module -// is back to its one honest job — the API-return decode. +// bridge_marshal — pure GetProjExtState result decode for the REAPER VST-host bridge read. // // Verified against vendor/reaper-sdk/sdk/reaper_plugin_functions.h: -// int GetProjExtState (ReaProject*, extname, key, valOutNeedBig, valOutNeedBig_sz); -// -- returns the length written (0 when the key is absent). +// int GetProjExtState(ReaProject*, extname, key, valOutNeedBig, valOutNeedBig_sz) +// returns the length written (0 when the key is absent). #pragma once @@ -26,18 +13,10 @@ namespace reasampler::instrument::map { -// Interpret a GetProjExtState result: the int return value (bytes the API reports for -// the key) and the buffer it filled. Returns the value only when the API reported a -// non-empty result AND the buffer is non-empty — REAPER writes 0 and leaves the buffer -// untouched for an absent key, and we must not treat stale buffer contents as a hit. -// -// `apiReturn` is GetProjExtState's return; `buffer` is the NUL-terminated string it -// wrote (already truncated to the C string by the caller). +// Value only when the API reported non-empty AND the buffer is non-empty — REAPER +// writes 0 and leaves the buffer untouched for an absent key, so stale buffer +// contents must never read as a hit. std::optional decodeGetProjExtState(int apiReturn, const std::string& buffer); -// The GetProjExtState GROW-LOOP retry policy (T2-04) lived here through Q-W5; it -// was rehomed to core/wire/ext_state_read.h in Q-W6 (its consumers are 2:1 -// extension-side, so it belongs on the neutral wire seam, not the instrument map). - } // namespace reasampler::instrument::map diff --git a/src/core/instrument/map/component_state_io.cpp b/src/core/instrument/map/component_state_io.cpp index bcec2b6..0fb93de 100644 --- a/src/core/instrument/map/component_state_io.cpp +++ b/src/core/instrument/map/component_state_io.cpp @@ -1,8 +1,6 @@ // component_state_io — the ComponentState envelope + zones-payload binary codec. See -// component_state_io.h for the format ladders (envelope v1..v11, zones payload v1..v7) -// and the why-a-separate-module note (Q-W2v, T4-13 ≡ T2-07). PURE: standard library + -// the pure sample_map value types + core/wire's LE byte codec (T4-20) + velocity_curve -// + master_gain. Every wire format is FROZEN — byte-identical to the pre-split writer. +// component_state_io.h for the format ladders (envelope v1..v11, zones payload v1..v7). +// Every wire format is FROZEN — byte-identical across revisions. #include "core/instrument/map/component_state_io.h" @@ -28,13 +26,11 @@ namespace { // Signed 64-bit values ride the wire as their two's-complement unsigned image. std::uint64_t asU64(std::int64_t v) { return static_cast(v); } -// Append the zones payload — the shared body of the performance blob and the component blob, -// so both write zones identically. Always emits the CURRENT PAYLOAD version (kZonesPayloadVersion -// == v5: the S11 self-describing marker + version + EXTENDED records carrying the loop/start tail -// AND the full play-params tail with wall-clock times stored as SECONDS): the marker precedes -// the zone count so any reader can detect the record shape independently of the envelope version -// (see sample_map.h). The S11 loop/start overrides and the play params therefore round-trip -// through EITHER envelope with no envelope bump. +// Append the zones payload — the shared body of the performance blob and the component +// blob, so both write zones identically. Always emits the CURRENT payload version (marker + +// version + extended records: loop/start tail + full play-params tail in SECONDS); the +// marker precedes the zone count so any reader can detect record shape independent of the +// envelope version (see sample_map.h). void putZonesPayload(std::vector& out, const PerformanceMap& map) { putLE(out, kZonesFormatMarker); putLE(out, kZonesPayloadVersion); @@ -49,7 +45,7 @@ void putZonesPayload(std::vector& out, const PerformanceMap& map) putLE(out, static_cast(static_cast(*z.rootOverride))); } - // S11 extension: loop override (hasLoop flag + start/end), then start point. + // loop override (hasLoop flag + start/end), then start point. out.push_back(z.loopOverride ? 1 : 0); if (z.loopOverride) { out.push_back(z.loopOverride->hasLoop ? 1 : 0); @@ -59,9 +55,9 @@ void putZonesPayload(std::vector& out, const PerformanceMap& map) out.push_back(z.startPoint ? 1 : 0); if (z.startPoint) putLE(out, asU64(*z.startPoint)); - // S15/S16 play params (PAYLOAD v5): always present (every zone has a play mode + engine). - // Wall-clock times are SECONDS (doubles); trigger %-length + fades stay source frames / - // fraction. Order matches the header's v5 record spec. + // Play params (PAYLOAD v5): always present. Wall-clock times are SECONDS (doubles); + // trigger %-length + fades stay source frames/fraction. Order matches the header's + // v5 record spec. const ZonePlaySeconds& pp = z.play; out.push_back(pp.playMode == PlayMode::Trigger ? 1 : 0); putLE(out, doubleToBits(pp.adsr.holdSeconds)); // wall-clock seconds @@ -78,10 +74,10 @@ void putZonesPayload(std::vector& out, const PerformanceMap& map) putLE(out, doubleToBits(pp.adsr.decaySeconds)); putLE(out, doubleToBits(pp.adsr.sustainLevel)); putLE(out, doubleToBits(pp.adsr.releaseSeconds)); - // PAYLOAD v6 (S-VIEW-6): the per-zone key-tracking scalar (1.0 = 100% ET). + // PAYLOAD v6: the per-zone key-tracking scalar (1.0 = 100% ET). putLE(out, doubleToBits(z.keyTrack)); - // PAYLOAD v7 (S-VIEW-9): the per-zone velocity->amp transfer curve, appended last. 4-byte LE - // control-point count, then per point velocity + amp as IEEE-754 doubles (endpoints included). + // PAYLOAD v7: the per-zone velocity->amp transfer curve, appended last. 4-byte LE + // control-point count, then per point velocity + amp as doubles (endpoints included). const std::vector& pts = z.velocityCurve.points(); putLE(out, static_cast(pts.size())); for (const VelocityPoint& p : pts) { @@ -91,30 +87,30 @@ void putZonesPayload(std::vector& out, const PerformanceMap& map) } } -// Read a zones payload from `r` into `map`. Shared by the performance parse and the component -// parse. Detects the S11 format marker: present -> PAYLOAD v2 (extended records with the -// loop/start tail); absent (a plain small zone count) -> PAYLOAD v1 (pre-S11 records, no tail — -// clean back-compat lift, the overrides simply default absent). A truncated mid-zone read -// keeps the zones that parsed cleanly and drops the rest. -// `projectRate` is the live host/project sample rate used to convert LEGACY v3 wall-clock frame -// counts (holdFrames, pitchEnv A/D) to the seconds domain at the read boundary: seconds = frames / -// projectRate. Must be > 0 (callers guard). v5 and later blobs carry seconds directly; no rate needed. +// Read a zones payload from `r` into `map`. Shared by the performance parse and the +// component parse. Detects the format marker: present -> PAYLOAD v2+ (extended records with +// the loop/start tail); absent (a plain small zone count) -> PAYLOAD v1 (no tail — clean +// back-compat lift, overrides default absent). A truncated mid-zone read keeps the zones +// that parsed cleanly and drops the rest. +// `projectRate` is the live host/project sample rate used to convert LEGACY v3 wall-clock +// frame counts (holdFrames, pitchEnv A/D) to seconds at the read boundary: seconds = frames +// / projectRate. Must be > 0 (callers guard). v5+ blobs carry seconds directly; no rate needed. void readZonesPayload(ByteReader& r, PerformanceMap& map, double projectRate) { - bool extended = false; // v2+: the S11 loop/start tail is present + bool extended = false; // v2+: the loop/start tail is present std::uint32_t pv = 0; // payload version (0 = v1, no marker) if (r.peekU32() == kZonesFormatMarker) { r.u32(); // consume the marker pv = r.u32(); // payload version extended = (pv >= 2); // v2+ carries the loop/start tail } - const bool legacyV3Play = (pv == 3); // legacy S15/S16 play tail, wall-clock in 44.1k frames + const bool legacyV3Play = (pv == 3); // legacy play tail, wall-clock in 44.1k frames const bool secondsPlay = (pv >= 5); // v5+: full play params, wall-clock in seconds - const bool keyTrackTail = (pv >= 6); // v6+ (S-VIEW-6): per-zone keyTrack scalar - const bool curveTail = (pv >= 7); // v7+ (S-VIEW-9): per-zone velocity->amp curve, appended last + const bool keyTrackTail = (pv >= 6); // v6+: per-zone keyTrack scalar + const bool curveTail = (pv >= 7); // v7+: per-zone velocity->amp curve, appended last const std::uint32_t count = r.u32(); for (std::uint32_t i = 0; i < count && r.ok; ++i) { - // z.play defaults to the PRODUCT defaults (Gate + Preserve + tier-0 AHDSR seconds). A - // v1/v2 payload (no play tail) therefore lifts every zone to those defaults (S16-F1). + // z.play defaults to the product defaults (Gate + Preserve + tier-0 AHDSR seconds). + // A v1/v2 payload (no play tail) lifts every zone to those defaults. PerformanceZone z; const std::uint32_t idLen = r.u32(); z.sampleId = r.str(idLen); @@ -135,10 +131,10 @@ void readZonesPayload(ByteReader& r, PerformanceMap& map, double projectRate) { if (hasStart) z.startPoint = r.i64(); } if (legacyV3Play) { - // LEGACY v3 play tail (Daniel's beta projects). Wall-clock fields (hold, pitchEnv A/D) - // were written as frames -> divide by the project sample rate (threaded in as `projectRate`) - // to reach the seconds domain. Trigger %-length + fades are source-timeline, read as-is. - // A/D/S/R are ABSENT in v3 -> leave the seconds defaults on z.play.adsr. + // LEGACY v3 play tail (Daniel's beta projects). Wall-clock fields (hold, pitchEnv + // A/D) were written as frames -> divide by `projectRate` to reach seconds. + // Trigger %-length + fades are source-timeline, read as-is. A/D/S/R are ABSENT + // in v3 -> leave the seconds defaults on z.play.adsr. assert(projectRate > 0.0 && "readZonesPayload: projectRate must be > 0 for v3 lift"); const double liftRate = projectRate > 0.0 ? projectRate : 1.0; // 1.0 avoids div-by-zero; assert fires first z.play.playMode = (r.u8() != 0) ? PlayMode::Trigger : PlayMode::Gate; @@ -169,21 +165,20 @@ void readZonesPayload(ByteReader& r, PerformanceMap& map, double projectRate) { z.play.adsr.sustainLevel = bitsToDouble(r.u64()); z.play.adsr.releaseSeconds = bitsToDouble(r.u64()); } - // PAYLOAD v6 (S-VIEW-6): the key-tracking scalar, appended after the v5 play tail. A pre-v6 - // payload (no field) leaves the PerformanceZone default (keyTrack = 1.0 = 100% ET), so an - // already-saved instance repitches BIT-IDENTICALLY to the pre-S-VIEW-6 engine. + // PAYLOAD v6: key-tracking scalar, appended after the v5 play tail. A pre-v6 payload + // (no field) leaves the PerformanceZone default (keyTrack = 1.0 = 100% ET), so an + // already-saved instance repitches BIT-IDENTICALLY. if (keyTrackTail) z.keyTrack = bitsToDouble(r.u64()); - // PAYLOAD v7 (S-VIEW-9): the velocity->amp transfer curve, appended after the v6 keyTrack. A - // pre-v7 payload (no field) leaves the PerformanceZone default (VelocityCurve::flat() — R10-F1 - // Option A, flat y=1), the deliberate NON-back-compat behavior change for already-saved zones. - // fromPoints repairs the X-order/endpoint invariant defensively; a truncated read (r.ok flips - // false mid-curve) leaves the flat default and the mid-zone break below drops the rest. + // PAYLOAD v7: velocity->amp transfer curve, appended after the v6 keyTrack. A pre-v7 + // payload (no field) leaves the PerformanceZone default (VelocityCurve::flat(), + // Daniel-approved), the deliberate NON-back-compat behavior change for already-saved + // zones. fromPoints repairs the X-order/endpoint invariant defensively; a truncated + // read leaves the flat default and the mid-zone break below drops the rest. if (curveTail) { const std::uint32_t ptCount = r.u32(); std::vector pts; - // Bound the reserve to what the blob can actually hold (16 bytes/point) so a corrupt huge - // count can't trigger a giant allocation before the bounded reads fail — the loop still - // stops on r.ok, this only caps the speculative reserve. + // Bound the reserve to what the blob can hold (16 bytes/point) so a corrupt huge + // count can't trigger a giant allocation before the bounded reads fail. const std::size_t remaining = r.bytes.size() > r.pos ? r.bytes.size() - r.pos : 0; pts.reserve(std::min(static_cast(ptCount), remaining / 16)); for (std::uint32_t p = 0; p < ptCount && r.ok; ++p) { @@ -211,15 +206,14 @@ std::vector serializePerformance(const PerformanceMap& map) { PerformanceMap deserializePerformance(const std::vector& bytes, double projectRate) { - // projectRate is only consumed by readZonesPayload when a LEGACY v3 payload is present. - // For v5 and later blobs it is unused. The assert inside readZonesPayload fires if a v3 - // blob is encountered with an invalid rate — the calller guarantees a real rate before use. + // projectRate is only consumed by readZonesPayload for a LEGACY v3 payload; unused for + // v5+. The assert inside readZonesPayload fires if a v3 blob has an invalid rate. PerformanceMap map; ByteReader r(bytes); const std::uint32_t version = r.u32(); if (!r.ok) return map; // no version tag -> empty - // BACK-COMPAT: a v1 blob is the S4 single-selection format (version 1 + id bytes, + // BACK-COMPAT: a v1 blob is the original single-selection format (version 1 + id bytes, // no length prefix). Lift it to one full-keyboard zone playing that id. if (version == kSelectionStateVersion) { const std::string id = deserializeSelection(bytes); @@ -238,33 +232,30 @@ PerformanceMap deserializePerformance(const std::vector& bytes, return map; } -// --- Combined component state (v3, S10) -------------------------------------- +// --- Combined component state -------------------------------------- std::vector serializeComponentState(const ComponentState& state) { std::vector out; putLE(out, kComponentStateVersion); - // v4 envelope addition: the channel mode (0 = mono, 1 = stereo) precedes the v3 body. + // v4 addition: channel mode (0 mono/1 stereo) precedes the v3 body. out.push_back(state.channelMode == ChannelMode::Stereo ? 1 : 0); - // v5 envelope addition (S8/S9 reader): the last-consumed assignment generation, 8-byte LE - // two's-complement, precedes the selection id. Follows the mode byte so a v4 reader that - // stops at the mode byte is a strict prefix (see the v4 lift below). + // v5 addition: last-consumed assignment generation, 8-byte LE two's-complement, follows + // the mode byte so a v4 reader stopping there is a strict prefix (see the v4 lift below). putLE(out, asU64(state.lastConsumedAssignGeneration)); - // v6 envelope addition (S-VIEW-4): the preview-trigger velocity, 1 byte (MIDI 1..127). Follows - // the marker so a v5 blob is a strict prefix of a v6 blob up to this byte (see the v5 lift). + // v6 addition: preview-trigger velocity, 1 byte (MIDI 1..127) — a v5 blob is a strict + // prefix up to this byte (see the v5 lift). out.push_back(state.previewVelocity); - // v7 envelope addition (Phase S voice system): voice count (1..32), voice mode (0 = Poly, - // 1 = Mono), mono trigger (0 = Retrigger, 1 = Legato) — one byte each, following the - // velocity byte so a v6 blob is a strict prefix up to here (see the v6 lift). + // v7 addition: voice count (1..32), voice mode (0 Poly/1 Mono), mono trigger + // (0 Retrigger/1 Legato) — one byte each, a v6 blob is a strict prefix up to here. const int vc = state.voiceCount < kMinVoiceCount ? kDefaultVoiceCount : state.voiceCount > kMaxVoiceCount ? kMaxVoiceCount : state.voiceCount; out.push_back(static_cast(vc)); out.push_back(state.voiceMode == VoiceMode::Mono ? 1 : 0); out.push_back(state.monoTrigger == MonoTrigger::Legato ? 1 : 0); - // v8 envelope addition (FB1 master gain): the post-mixer LINEAR gain as an IEEE-754 double - // (bit-cast to u64 LE), following the voice bytes so a v7 blob is a strict prefix up to - // here (see the v7 lift). The WRITER never emits an out-of-range value: non-finite or - // negative falls back to unity; above the +24 dB cap clamps to the cap. + // v8 addition: post-mixer LINEAR gain as a double (bit-cast to u64 LE) — a v7 blob is a + // strict prefix up to here. The WRITER never emits out-of-range: non-finite/negative + // falls back to unity; above the +24 dB cap clamps to the cap. { double g = state.masterGainLinear; const double maxLin = masterGainMaxLinear(); @@ -272,16 +263,14 @@ std::vector serializeComponentState(const ComponentState& state) { if (g > maxLin) g = maxLin; putLE(out, doubleToBits(g)); } - // v9 envelope addition (GA channel-mode auto-default): the channel-mode-EXPLICIT flag, - // 1 byte, following the gain double so a v8 blob is a strict prefix up to here (see the - // v8 lift). 0 = implicit (the shell may auto-default the mode from the loaded capture's - // channel count); 1 = the user deliberately toggled the mode (never fought). + // v9 addition: channel-mode-EXPLICIT flag, 1 byte — a v8 blob is a strict prefix up to + // here. 0 = implicit (shell may auto-default from the loaded capture's channel count); + // 1 = user deliberately toggled the mode (never fought). out.push_back(state.channelModeExplicit ? 1 : 0); - // v10 envelope addition (pS self-contained playback): the instance-owned sample-refs - // table, following the explicit flag so a v9 blob is a strict prefix up to here (see - // the v9 lift). Wire shape per kSelectionZonesRefsV10Version: entry count, then per - // entry id + path (length-prefixed), rootNote, loop (hasLoop + start/end, always - // written), channelCount, displayName (length-prefixed; display-only). + // v10 addition: the instance-owned sample-refs table — a v9 blob is a strict prefix up + // to here. Wire shape per kSelectionZonesRefsV10Version: entry count, then per entry id + // + path (length-prefixed), rootNote, loop (hasLoop + start/end, always written), + // channelCount, displayName (length-prefixed; display-only). putLE(out, static_cast(state.sampleRefs.size())); for (const SampleRefEntry& e : state.sampleRefs) { putLE(out, static_cast(e.sampleId.size())); @@ -312,17 +301,17 @@ std::vector serializeComponentState(const ComponentState& state) { ComponentState deserializeComponentState(const std::vector& bytes, double projectRate) { - // projectRate is only consumed by readZonesPayload when a LEGACY v3 payload is present. - // For v5 and later blobs it is unused. See readZonesPayload for the guard. + // projectRate is only consumed by readZonesPayload for a LEGACY v3 payload; unused for + // v5+. See readZonesPayload for the guard. ComponentState out; ByteReader r(bytes); const std::uint32_t version = r.u32(); - if (!r.ok) return out; // no version tag -> empty (the S10 silent empty state) + if (!r.ok) return out; // no version tag -> empty (the silent empty state) // BACK-COMPAT: an older blob predates the v3 {selection, zones} split. - // * v1 (S4 single-selection: version 1 + id-to-end): restore {id, one full-keyboard - // zone} so the old pick survives as BOTH the selection and a one-zone map. - // * v2 (S5 zones-only): restore {"", zones} — that instance had zones but no separate + // * v1 (original single-selection: version 1 + id-to-end): restore {id, one + // full-keyboard zone} so the old pick survives as BOTH the selection and a one-zone map. + // * v2 (zones-only): restore {"", zones} — that instance had zones but no separate // single-capture selection. if (version == kSelectionStateVersion) { out.selectionId = deserializeSelection(bytes); @@ -337,20 +326,20 @@ ComponentState deserializeComponentState(const std::vector& bytes, } if (version == kPerformanceStateVersion) { readZonesPayload(r, out.map, projectRate); // v2 body starts right after the version tag - return out; // channelMode stays Mono (pre-S7) + return out; // channelMode stays Mono } - // BACK-COMPAT: a v3 blob (pre-S7 {selection, zones}, no channel mode) restores as MONO — - // the id length + id + zones body starts right after the version tag (no mode byte). + // BACK-COMPAT: a v3 blob ({selection, zones}, no channel mode) restores as MONO — the id + // length + id + zones body starts right after the version tag (no mode byte). if (version == kSelectionZonesV3Version) { const std::uint32_t idLen = r.u32(); out.selectionId = r.str(idLen); if (!r.ok) { out.selectionId.clear(); return out; } // truncated id -> empty readZonesPayload(r, out.map, projectRate); - return out; // channelMode stays Mono, marker stays 0 (pre-S7/S8/S9) + return out; // channelMode stays Mono, marker stays 0 } - // BACK-COMPAT: a v4 blob (pre-S8/S9 reader {mode, selection, zones}, no consumed marker): - // mode byte, then the id + zones body — no 8-byte marker. lastConsumedAssignGeneration - // defaults to 0, so a first assign still applies for a pre-marker instance. + // BACK-COMPAT: a v4 blob ({mode, selection, zones}, no consumed marker): mode byte, then + // the id + zones body — no 8-byte marker. lastConsumedAssignGeneration defaults to 0, so + // a first assign still applies for a pre-marker instance. if (version == kSelectionZonesModeV4Version) { const std::uint8_t modeByte = r.u8(); if (!r.ok) return out; // truncated before the mode byte -> empty (mono default holds) @@ -359,12 +348,12 @@ ComponentState deserializeComponentState(const std::vector& bytes, out.selectionId = r.str(idLen); if (!r.ok) { out.selectionId.clear(); return out; } // truncated id -> empty readZonesPayload(r, out.map, projectRate); - return out; // marker stays 0 (pre-S8/S9 reader) + return out; // marker stays 0 } - // BACK-COMPAT: a v5 blob (pre-S-VIEW-4 {mode, marker, selection, zones}, no preview-velocity - // byte): mode byte, then the 8-byte marker, then the id + zones body — no velocity byte. - // previewVelocity defaults to kPreviewVelocityDefault (set at construction), so an already-saved - // pre-S-VIEW-4 instance restores at the mid default. + // BACK-COMPAT: a v5 blob ({mode, marker, selection, zones}, no preview-velocity byte): + // mode byte, then the 8-byte marker, then the id + zones body — no velocity byte. + // previewVelocity defaults to kPreviewVelocityDefault (construction default), so an + // already-saved instance restores at the mid default. if (version == kSelectionZonesModeMarkerV5Version) { const std::uint8_t modeByte = r.u8(); if (!r.ok) return out; // truncated before the mode byte -> empty (mono default holds) @@ -375,7 +364,7 @@ ComponentState deserializeComponentState(const std::vector& bytes, out.selectionId = r.str(idLen); if (!r.ok) { out.selectionId.clear(); return out; } // truncated id -> empty readZonesPayload(r, out.map, projectRate); - return out; // previewVelocity stays at the mid default (pre-S-VIEW-4) + return out; // previewVelocity stays at the mid default } if (version != kComponentStateVersion && version != kSelectionZonesRefsV10Version && @@ -386,10 +375,9 @@ ComponentState deserializeComponentState(const std::vector& bytes, return out; // unknown -> empty } - // v6..v10 shared prefix: the channel-mode byte, then the 8-byte consumed-assignment marker, - // then the 1-byte preview velocity, precede the v3 body. A non-{0,1} mode byte is treated - // as mono (conservative default) rather than rejected — a corrupt mode never silences the - // instance. + // v6..v10 shared prefix: channel-mode byte, 8-byte consumed-assignment marker, 1-byte + // preview velocity, precede the v3 body. A non-{0,1} mode byte treats as mono + // (conservative default) rather than rejected — a corrupt mode never silences the instance. const std::uint8_t modeByte = r.u8(); if (!r.ok) return out; // truncated before the mode byte -> empty (mono default holds) out.channelMode = (modeByte == 1) ? ChannelMode::Stereo : ChannelMode::Mono; @@ -402,8 +390,8 @@ ComponentState deserializeComponentState(const std::vector& bytes, out.previewVelocity = (previewVel >= 1 && previewVel <= 127) ? previewVel : kPreviewVelocityDefault; - // v7+ (Phase S): the three voice-system bytes. A v6 blob (pre-Phase-S) skips them — the - // construction defaults {16, Poly, Retrigger} hold, reproducing pre-Phase-S behavior. + // v7+: the three voice-system bytes. A v6 blob skips them — the construction defaults + // {16, Poly, Retrigger} hold, reproducing pre-voice-system behavior. if (version >= kSelectionZonesModeMarkerVelVoiceV7Version) { const std::uint8_t vc = r.u8(); const std::uint8_t vm = r.u8(); @@ -417,9 +405,9 @@ ComponentState deserializeComponentState(const std::vector& bytes, out.voiceMode = (vm == 1) ? VoiceMode::Mono : VoiceMode::Poly; out.monoTrigger = (mt == 1) ? MonoTrigger::Legato : MonoTrigger::Retrigger; } - // v8+ (FB1): the master-gain LINEAR double. A v7 blob (pre-FB1) skips it — the construction - // default (unity) holds, reproducing pre-FB1 output exactly. A non-finite, negative, or - // above-cap value (a corrupt blob) falls back to unity rather than silencing/blasting. + // v8+: the master-gain LINEAR double. A v7 blob skips it — the construction default + // (unity) holds. A non-finite, negative, or above-cap value falls back to unity rather + // than silencing/blasting. if (version >= kSelectionZonesModeMarkerVelVoiceGainV8Version) { const double g = bitsToDouble(r.u64()); if (!r.ok) return out; // truncated inside the gain double — out already carries @@ -429,18 +417,18 @@ ComponentState deserializeComponentState(const std::vector& bytes, ? g : 1.0; } - // v9 (GA): the channel-mode-EXPLICIT flag. A v8-or-older blob skips it — the construction - // default (false = implicit) holds, so an already-saved instance's mode is treated as the - // un-touched default and the shell may auto-default it from the loaded capture. + // v9: the channel-mode-EXPLICIT flag. A v8-or-older blob skips it — the construction + // default (false = implicit) holds, so an already-saved instance's mode is treated as + // the untouched default and the shell may auto-default it from the loaded capture. if (version >= kSelectionZonesModeMarkerVelVoiceGainExplicitV9Version) { const std::uint8_t explicitByte = r.u8(); if (!r.ok) return out; // truncated before the flag -> empty (implicit holds) out.channelModeExplicit = (explicitByte == 1); } - // v10 (pS self-contained playback): the sample-refs table. A v9-or-older blob skips it — - // the EMPTY-table default holds, and the shell lifts the refs once via the bridge-resolve - // path (then re-saves self-contained). A truncated mid-entry read keeps the entries that - // parsed cleanly and drops the rest (the selection/zones behind it are unreadable anyway). + // v10: the sample-refs table. A v9-or-older blob skips it — the EMPTY-table default + // holds, and the shell lifts the refs once via the bridge-resolve path (then re-saves + // self-contained). A truncated mid-entry read keeps the entries that parsed cleanly and + // drops the rest (the selection/zones behind it are unreadable anyway). if (version >= kSelectionZonesRefsV10Version) { const std::uint32_t refCount = r.u32(); for (std::uint32_t i = 0; i < refCount && r.ok; ++i) { @@ -449,11 +437,10 @@ ComponentState deserializeComponentState(const std::vector& bytes, e.sampleId = r.str(refIdLen); const std::uint32_t pathLen = r.u32(); e.ref.relativePath = r.str(pathLen); - // Range fallbacks (the refs table is the ONLY copy on the play path, so a - // corrupt field must degrade to the field's default, never poison playback — - // the previewVelocity/voiceCount posture): an out-of-MIDI-range root falls back - // to the middle-C default distill() uses; a negative channel count falls back - // to 0 = unknown (the GA auto-default then skips it). + // Range fallbacks: the refs table is the ONLY copy on the play path, so a + // corrupt field must degrade to the field's default, never poison playback. An + // out-of-MIDI-range root falls back to the middle-C default distill() uses; a + // negative channel count falls back to 0 = unknown (auto-default then skips it). const std::int32_t root = r.i32(); e.ref.rootNote = (root >= 0 && root <= 127) ? root : 60; e.ref.loop.hasLoop = (r.u8() != 0); @@ -468,8 +455,8 @@ ComponentState deserializeComponentState(const std::vector& bytes, } if (!r.ok) return out; } - // v11 (pS-usage): the minted instance guid. A v10-or-older blob skips it — the - // EMPTY default holds and the processor mints a fresh identity on first publish. + // v11: the minted instance guid. A v10-or-older blob skips it — the EMPTY default + // holds and the processor mints a fresh identity on first publish. if (version >= kSelectionZonesRefsIdentityV11Version) { const std::uint32_t guidLen = r.u32(); out.instanceGuid = r.str(guidLen); diff --git a/src/core/instrument/map/component_state_io.h b/src/core/instrument/map/component_state_io.h index a66b1f4..104a611 100644 --- a/src/core/instrument/map/component_state_io.h +++ b/src/core/instrument/map/component_state_io.h @@ -1,20 +1,15 @@ #pragma once // component_state_io — the ComponentState ENVELOPE + zones-payload binary codec for the -// ReaSampler 9000 instrument (Q-W2v split out of sample_map, T4-13 ≡ T2-07). PURE: NO -// VST3, NO REAPER, NO SWELL, NO vendor/ includes — the same boundary sample_map keeps. +// ReaSampler 9000 instrument. Split out of sample_map so both artifacts can share it: the +// instrument's processor reads/writes it at setState/getState, and the extension's +// instrument-drop path serializes the identical bytes into a transient .vstpreset, so the +// payload and the instrument's reader can never drift — without the extension having to +// link the whole voice engine (sampler_core + pitch_shift) just to serialize one preset +// blob. Its own links are velocity_curve + master_gain (wire value validation), never the +// engine. // -// WHY A SEPARATE MODULE. The codec grows on EVERY ComponentState envelope bump (v6→v11 -// in one quarter), and it is deliberately shared across BOTH artifacts: the instrument's -// processor reads/writes it at setState/getState, and the EXTENSION's instrument-drop -// path (core/wire/instrument_drop) serializes the same bytes into a transient .vstpreset -// so the payload and the instrument's reader can never drift. Housing it inside -// sample_map made the extension link the whole voice engine (sampler_core + pitch_shift) -// to serialize one preset blob; split out, both artifacts link the codec and only the -// VST links the engine. The codec's own links are velocity_curve + master_gain (wire -// value validation) — never the engine. -// -// EVERY wire format below is FROZEN (byte-identical to the pre-split writer); the full -// version ladders (envelope v1..v11, zones payload v1..v7) are preserved exactly. +// EVERY wire format below is FROZEN; the full version ladders (envelope v1..v11, zones +// payload v1..v7) must be preserved exactly. #include #include @@ -26,108 +21,86 @@ namespace reasampler::instrument::map { // --- Performance-map instance state (VST3 setState/getState) ----------------- // -// The performance map is the instrument's OWN state (D-B), serialized to the VST3 -// component-state IBStream — NOT written to the "reasampler" bank ext-state (the -// instrument is a read-only bank consumer; S4 precedent). Versioned binary, tolerant of -// truncation/wrong-version by design (bounded reads, never throws across the host). +// The performance map is the instrument's OWN state, serialized to the VST3 component-state +// IBStream — never written to the "reasampler" bank ext-state. Versioned binary, tolerant +// of truncation/wrong-version (bounded reads, never throws across the host). // // Format: 4-byte LE ENVELOPE version tag (== kPerformanceStateVersion, == 2), then the // ZONES PAYLOAD. // -// ZONES-PAYLOAD FORMAT VERSIONING (S11 — self-describing, envelope-independent). The zones -// payload carries its OWN version so the per-zone record can grow (S11's loop/start overrides) -// WITHOUT bumping the envelope version — the envelope (this v2 blob and the v3 ComponentState -// below, and S7's forthcoming v4) simply wraps whatever payload version it holds. This is the -// key composition property: the zone-record extension is versioned inside the map blob, not on -// the envelope, so S11 (zone-record fields) and S7 (envelope v4 for channel mode) do not -// collide on a single version number. -// * PAYLOAD v1 (pre-S11, on-the-wire shipped): 4-byte LE zone count, then per zone: -// 4-byte LE id length, id bytes, 4-byte LE lowNote, 4-byte LE highNote, -// 1 byte hasRootOverride (0/1), 4-byte LE rootOverride (present iff hasRootOverride). -// A payload starting with a small u32 (the zone count) is v1 — there is no marker. -// * PAYLOAD v2 (S11): a 4-byte LE MARKER (kZonesFormatMarker, a high sentinel no real zone -// count can equal) + a 4-byte LE payload version (== 2), THEN the v1 body PLUS, appended -// to each zone record after rootOverride: -// 1 byte hasLoopOverride (0/1); iff set: 1 byte loop.hasLoop, 8-byte LE loop.start, -// 8-byte LE loop.end (both two's-complement int64); -// 1 byte hasStartPoint (0/1); iff set: 8-byte LE startPoint (two's-complement int64). -// The reader detects the marker to know the record shape — a v1 payload (no marker) reads -// the shorter record; a v2 payload reads the extended one. Both compose under ANY envelope. -// * PAYLOAD v3 (S15/S16, LEGACY — exists in Daniel's beta projects): the same marker + payload -// version (== 3), THEN the v2 body PLUS, appended to each zone record after the S11 startPoint -// tail (the S15/S16 per-zone play params — always present, NOT flag-gated): -// 1 byte playMode (0 = Gate, 1 = Trigger); -// 8-byte LE adsr.holdFrames (int64) — the S15 AHDSR hold stage, FRAMES at 44.1k nominal; -// 8-byte LE trigger.lengthFraction as an IEEE-754 double (bit-cast to u64 LE); -// 8-byte LE trigger.fadeInFrames (int64); 8-byte LE trigger.fadeOutFrames (int64); -// 1 byte pitchEngine (0 = Varispeed, 1 = Preserve); -// 1 byte pitchEnv.enabled (0/1); 8-byte LE pitchEnv.attackFrames (int64, FRAMES 44.1k nom); -// 8-byte LE pitchEnv.decayFrames (int64, FRAMES 44.1k nom); 8-byte LE peakSemitones double. -// A v1/v2 payload (no v3 tail) lifts each zone to the PRODUCT defaults (Gate + Preserve + -// no fades + disabled pitch env) — the deliberate S16-F1 behavior change for already-saved -// instruments. A truncated mid-v3-tail record keeps the zones that parsed and drops the rest. -// LEGACY-READ CONVERSION (S12): the v3 wall-clock frame counts (hold, pitchEnv A/D) were ALWAYS -// written by the S15/S16 editor as nominal frames at a baked-in rate. They convert to the seconds -// domain by dividing by the PROJECT sample rate threaded into the v3 lift path at read time (passed -// as a parameter — no constant). Source-timeline fields (trigger %-length + fades) stay frames. -// A/D/S/R are absent in v3 -> lifted to the tier-0 seconds defaults (0.003 / 0 / 1.0 / 0.060). -// * PAYLOAD v5 (S12 remediation — CURRENT WRITE FORMAT): the same marker + payload version (== 5), -// THEN the v2 body PLUS, appended to each zone record after the S11 startPoint tail, the full -// per-zone play params with WALL-CLOCK TIMES STORED AS SECONDS (rate-free, IEEE-754 doubles): -// 1 byte playMode (0 = Gate, 1 = Trigger); -// 8-byte LE adsr.holdSeconds (double); 8-byte LE trigger.lengthFraction (double); -// 8-byte LE trigger.fadeInFrames (int64); 8-byte LE trigger.fadeOutFrames (int64); -// 1 byte pitchEngine; 1 byte pitchEnv.enabled; -// 8-byte LE pitchEnv.attackSeconds (double); 8-byte LE pitchEnv.decaySeconds (double); -// 8-byte LE pitchEnv.peakSemitones (double); -// 8-byte LE adsr.attackSeconds (double); 8-byte LE adsr.decaySeconds (double); -// 8-byte LE adsr.sustainLevel (double); 8-byte LE adsr.releaseSeconds (double). -// Trigger fades stay int64 SOURCE frames (a source-timeline fact, PLAN.md §S15). PAYLOAD v4 -// (the branch-only frames-tail) was NEVER shipped and is intentionally dropped from the reader -// — a v4 blob cannot exist outside this branch. The keymap builders resolve the stored seconds -// to frames at the LIVE sample rate; no rate is baked into storage or the program. -// BACK-COMPAT: a v1 ENVELOPE blob (the S4 single-selection format: version tag 1 + id bytes) is -// lifted to a single full-keyboard zone playing that id (no override) — so an instance saved -// under Tier 0 restores as a one-zone Tier-1 map. A truncated/unknown/empty blob deserializes -// to an EMPTY map. +// ZONES-PAYLOAD FORMAT VERSIONING is self-describing and envelope-independent: the payload +// carries its OWN version, so the per-zone record can grow without bumping the envelope +// version. Zone-record extensions and envelope-field additions stay on independent axes +// that can never collide on one version number. +// * v1 (original, no marker): 4-byte LE zone count, then per zone: 4-byte LE id length + +// id bytes, 4-byte LE lowNote, 4-byte LE highNote, 1 byte hasRootOverride, 4-byte LE +// rootOverride (iff hasRootOverride). A payload starting with a small u32 (zone count) +// is v1. +// * v2: 4-byte LE MARKER (kZonesFormatMarker, a high sentinel no real zone count can +// equal) + 4-byte LE payload version (== 2), then the v1 body PLUS, per zone record +// after rootOverride: 1 byte hasLoopOverride; iff set, 1 byte loop.hasLoop + 8-byte LE +// loop.start + loop.end (int64); 1 byte hasStartPoint; iff set, 8-byte LE startPoint +// (int64). The marker lets the reader detect record shape independent of the envelope. +// * v3 (LEGACY — exists in Daniel's beta projects): marker + version (== 3), v2 body PLUS +// a per-zone play-params tail (always present): 1 byte playMode (0 Gate/1 Trigger); +// 8-byte LE adsr.holdFrames (int64, FRAMES at 44.1k nominal); 8-byte LE +// trigger.lengthFraction (double); 8-byte LE trigger.fadeInFrames + fadeOutFrames +// (int64); 1 byte pitchEngine (0 Varispeed/1 Preserve); 1 byte pitchEnv.enabled; 8-byte +// LE pitchEnv.attackFrames + decayFrames (int64, FRAMES 44.1k nom); 8-byte LE +// peakSemitones (double). A v1/v2 payload (no v3 tail) lifts each zone to the product +// defaults (Gate + Preserve, no fades, pitch env disabled) — deliberate for +// already-saved instruments. A truncated mid-v3-tail record keeps the zones that parsed. +// LEGACY-READ CONVERSION: the v3 wall-clock frame counts (hold, pitchEnv A/D) were +// always written as nominal frames at a baked-in rate; convert to seconds by dividing by +// the PROJECT sample rate threaded into the v3 lift path at read time (a parameter, no +// baked constant). Source-timeline fields (trigger %-length + fades) stay frames. A/D/S/R +// absent in v3 -> tier-0 seconds defaults (0.003/0/1.0/0.060). +// * v5 (CURRENT WRITE FORMAT): marker + version (== 5), v2 body PLUS, per zone record, the +// full play params with WALL-CLOCK TIMES AS SECONDS (rate-free doubles): 1 byte +// playMode; 8-byte LE adsr.holdSeconds; 8-byte LE trigger.lengthFraction; 8-byte LE +// trigger.fadeInFrames + fadeOutFrames (int64, unchanged — source-timeline facts); 1 +// byte pitchEngine; 1 byte pitchEnv.enabled; 8-byte LE pitchEnv.attackSeconds + +// decaySeconds + peakSemitones; 8-byte LE adsr.attackSeconds + decaySeconds + +// sustainLevel + releaseSeconds. v4 (a branch-only frames-tail) was never shipped and is +// intentionally not read. Keymap builders resolve stored seconds to frames at the LIVE +// sample rate; no rate is baked into storage or the program. +// BACK-COMPAT: a v1 ENVELOPE blob (the original single-selection format: version tag 1 + id +// bytes) lifts to a single full-keyboard zone playing that id (no override). A +// truncated/unknown/empty blob deserializes to an EMPTY map. // -// These two functions serialize the ZONES only. Since S10 the instrument's full component -// state is {single-capture selection id, zones} — see ComponentState / serializeComponentState -// below, the v3 format the processor actually reads/writes. serializePerformance/ -// deserializePerformance are retained for the zones payload + the v1/v2 back-compat lift. +// These two functions serialize the ZONES only; the instrument's full component state is +// {single-capture selection id, zones} — see ComponentState / serializeComponentState below. inline constexpr std::uint32_t kPerformanceStateVersion = 2; -// The zones-payload format version and its detection marker (S11/S15/S16/S12/S-VIEW-6/S-VIEW-9). -// serializePerformance and serializeComponentState both emit the CURRENT payload version (v7 — -// marker + version + records with the S11 loop/start tail, the full play-params tail with wall-clock -// times in SECONDS, the v6 keyTrack scalar, and the v7 velocity->amp curve) so the overrides -// round-trip through EITHER envelope. Readers accept a v1 payload (no marker), a v2 payload (marker + -// version 2, no play tail), and a v3 payload (legacy S15/S16 play tail with wall-clock frame counts) -// for back-compat, lifting missing fields to defaults. v4 was never shipped and is not read. The -// marker is a high sentinel that a legitimate zone count (bounded by 128 MIDI zones in practice, -// always tiny) can never collide with. -// * PAYLOAD v6 (S-VIEW-6): identical to v5, PLUS one field appended to each zone record after the -// full v5 play-params tail: -// 8-byte LE keyTrack (IEEE-754 double) — the per-zone key-tracking scalar (1.0 = 100% ET). -// A v1–v5 payload (no keyTrack field) lifts every zone to keyTrack = 1.0 (the PerformanceZone -// default), so already-saved instances are BIT-IDENTICAL — the 100% default reproduces the -// pre-S-VIEW-6 repitch exactly. A truncated mid-keyTrack record keeps the zones that parsed. -// * PAYLOAD v7 (S-VIEW-9 — CURRENT WRITE FORMAT): identical to v6, PLUS the per-zone velocity->amp -// transfer curve appended to each zone record after the v6 keyTrack field: -// 4-byte LE control-point count N, then per point: 8-byte LE velocity (double), 8-byte LE amp -// (double). The two endpoints (velocity 0 and 127) are always included, so N >= 2. -// A v1–v6 payload (no velocity-curve field) lifts every zone to VelocityCurve::flat() (R10-F1 -// Option A — flat y=1). This is a DELIBERATE, Daniel-approved NON-back-compat behavior change: -// an already-saved zone's soft hits play LOUDER than under the pre-r10 linear velocity/127. A -// truncated mid-curve record leaves the zone's flat default and keeps the zones that parsed. -inline constexpr std::uint32_t kZonesPayloadVersion = 7; // S-VIEW-9: + per-zone velocity->amp curve +// The zones-payload format version and its detection marker. serializePerformance and +// serializeComponentState both emit the CURRENT payload version (v7: marker + version + +// records with the loop/start tail, the full play-params tail in SECONDS, the v6 keyTrack +// scalar, and the v7 velocity->amp curve) so overrides round-trip through EITHER envelope. +// Readers accept v1 (no marker), v2 (marker + version 2, no play tail), and v3 (legacy play +// tail, wall-clock frame counts) for back-compat, lifting missing fields to defaults. v4 was +// never shipped and is not read. The marker is a high sentinel no legitimate zone count +// (bounded by 128 MIDI zones, always tiny) can ever collide with. +// * PAYLOAD v6: identical to v5, PLUS one field appended to each zone record after the +// full v5 play-params tail: 8-byte LE keyTrack (double) — the per-zone key-tracking +// scalar (1.0 = 100% ET). A v1-v5 payload (no keyTrack) lifts every zone to keyTrack = +// 1.0, so already-saved instances are BIT-IDENTICAL — the default reproduces the prior +// repitch exactly. A truncated mid-keyTrack record keeps the zones that parsed. +// * PAYLOAD v7 (CURRENT WRITE FORMAT): identical to v6, PLUS the per-zone velocity->amp +// transfer curve appended after the v6 keyTrack field: 4-byte LE control-point count N, +// then per point 8-byte LE velocity + 8-byte LE amp (doubles). The two endpoints +// (velocity 0 and 127) are always included, so N >= 2. A v1-v6 payload (no +// velocity-curve field) lifts every zone to VelocityCurve::flat() (Daniel-approved). +// This is a DELIBERATE NON-back-compat behavior change: an already-saved zone's soft +// hits play LOUDER than under the old linear velocity/127. A truncated mid-curve record +// leaves the zone's flat default and keeps the zones that parsed. +inline constexpr std::uint32_t kZonesPayloadVersion = 7; // + per-zone velocity->amp curve inline constexpr std::uint32_t kZonesFormatMarker = 0xFFFFFF00u; -// (No kLegacyV3NominalRate constant.) The legacy v3 zone payload's wall-clock frame counts are -// converted to seconds at the v3 read boundary using the PROJECT sample rate threaded in as a -// parameter — frames ÷ projectRate = seconds. The project rate is the same rate keymap build -// already receives, so the seconds domain is consistent across both paths. No constant is baked in. +// (No kLegacyV3NominalRate constant.) The legacy v3 zone payload's wall-clock frame counts +// convert to seconds at the v3 read boundary using the PROJECT sample rate threaded in as a +// parameter (frames / projectRate = seconds) — the same rate keymap build already receives, +// so the seconds domain is consistent across both paths. No constant is baked in. // The performance map serialized to bytes for IBStream (getState). std::vector serializePerformance(const PerformanceMap& map); @@ -139,152 +112,145 @@ std::vector serializePerformance(const PerformanceMap& map); PerformanceMap deserializePerformance(const std::vector& bytes, double projectRate); -// --- Combined component state (VST3 setState/getState, v3 — S10) ------------- +// --- Combined component state (VST3 setState/getState, v3+) ------------- // -// Since S10 the single-capture SELECTION and the opt-in ZONES are distinct concepts that +// The single-capture SELECTION and the opt-in ZONES are distinct concepts that // BOTH persist: the default face is one picked capture (the selection id), and zones are a // demoted opt-in overlay (the performance map). The component state carries both so a saved -// project restores an instance's pick AND its zones — and, per the S10 policy reversal, an -// instance with NO pick and NO zones restores EMPTY (silence + the "pick a capture" empty -// state), never auto-playing sample #1. +// project restores an instance's pick AND its zones — and an instance with NO pick and NO +// zones restores EMPTY (silence + the "pick a capture" empty state), never auto-playing +// sample #1. // -// Format (envelope v10): 4-byte LE version tag (== 10), then a 1-byte channel-mode field (0 = mono, -// 1 = stereo), then an 8-byte LE last-consumed-assignment generation (S8/S9 reader marker), then a -// 1-byte preview-trigger velocity (S-VIEW-4, MIDI 1..127), then the THREE Phase-S voice-system -// bytes: a 1-byte voice count (1..32), a 1-byte voice mode (0 = Poly, 1 = Mono), a 1-byte mono -// trigger (0 = Retrigger, 1 = Legato), then the FB1 8-byte LE master-gain LINEAR value (IEEE-754 -// double, bit-cast; 0.0 = -inf/silence, 1.0 = unity, cap ~15.849 = +24 dB), then the GA 1-byte -// channel-mode-EXPLICIT flag (0 = implicit/auto-default, 1 = the user deliberately toggled the -// mode — see ComponentState::channelModeExplicit), then the pS SAMPLE-REFS table (v10 — the -// instance-owned path + intrinsics + display name per referenced sample; wire shape at -// kSelectionZonesRefsV10Version below), then the pS-usage INSTANCE GUID (v11 — a 4-byte LE -// length + guid bytes; the minted per-instance identity the usage publisher keys its -// "rsusage_" ext-state record under, see sample_usage.h), then a 4-byte LE -// selection-id length + id bytes, then the CURRENT zones payload (identical to -// serializePerformance's body — its own self-describing version, see the ZONES-PAYLOAD block). -// The instance guid is the ONLY envelope-v11 addition over v10, as the refs table was the -// only v10 addition over v9 — the envelope grows a field, -// the zones payload is untouched (a PARALLEL track owns zone-record extension under its own -// versioning; the two version numbers are independent axes — do NOT bump the zones-payload -// version for an envelope field). An out-of-range voice byte or a non-finite/out-of-range -// master-gain double (a corrupt blob) falls back to the field's default rather than silencing -// the instance (the previewVelocity precedent). BACK-COMPAT on read (every older blob lifts to -// channelMode = MONO, lastConsumedAssignGeneration = 0, previewVelocity = -// kPreviewVelocityDefault, the Phase-S voice defaults {16 voices, Poly, Retrigger}, unity -// master gain, channelModeExplicit = FALSE — a pre-v9 mode byte is treated as the -// un-touched default, so the GA auto-default may follow the loaded capture; a user who HAD -// deliberately chosen a mode re-toggles once and the choice persists explicit from then on — -// and an EMPTY sample-refs table, which the shell lifts once via the bridge-resolve path — -// and an EMPTY instance guid (pre-pS-usage), which the shell re-mints on first publish): +// Format (envelope v11): 4-byte LE version tag (== 11); 1-byte channel-mode field (0 +// mono/1 stereo); 8-byte LE last-consumed-assignment generation; 1-byte preview-trigger +// velocity (MIDI 1..127); three voice-system bytes (1-byte voice count 1..32, 1-byte voice +// mode 0 Poly/1 Mono, 1-byte mono trigger 0 Retrigger/1 Legato); 8-byte LE master-gain +// LINEAR value (double, bit-cast; 0.0 = -inf/silence, 1.0 = unity, cap ~15.849 = +24 dB); +// 1-byte channel-mode-EXPLICIT flag (0 implicit/auto-default, 1 = user deliberately +// toggled — see ComponentState::channelModeExplicit); the SAMPLE-REFS table (instance-owned +// path + intrinsics + display name per referenced sample; wire shape at +// kSelectionZonesRefsV10Version below); the INSTANCE GUID (4-byte LE length + guid bytes — +// the minted per-instance identity the usage publisher keys its "rsusage_" ext-state +// record under, see sample_usage.h); 4-byte LE selection-id length + id bytes; then the +// CURRENT zones payload (identical to serializePerformance's body — its own self-describing +// version). The instance guid is the only v11 addition over v10, as the refs table was the +// only v10 addition over v9 — the envelope grows a field, the zones payload is untouched (a +// PARALLEL track owns zone-record extension under its own versioning — the two version +// numbers are independent axes; do NOT bump the zones-payload version for an envelope +// field). An out-of-range voice byte or a non-finite/out-of-range master-gain double (a +// corrupt blob) falls back to the field's default rather than silencing the instance. +// BACK-COMPAT on read (every older blob lifts to channelMode = MONO, +// lastConsumedAssignGeneration = 0, previewVelocity = kPreviewVelocityDefault, voice +// defaults {16 voices, Poly, Retrigger}, unity master gain, channelModeExplicit = FALSE — a +// pre-v9 mode byte is treated as the untouched default so the auto-default may follow the +// loaded capture, and a user who HAD deliberately chosen a mode re-toggles once and the +// choice persists explicit from then on — and an EMPTY sample-refs table, which the shell +// lifts once via the bridge-resolve path — and an EMPTY instance guid, which the shell +// re-mints on first publish): // * v11 blob -> {channelMode, marker, previewVelocity, voice bytes, masterGainLinear, explicit, sampleRefs, instanceGuid, selectionId, zones} direct. -// * v10 blob -> the v11 fields minus instanceGuid (empty — minted on first publish): pre-pS-usage. -// * v9 blob -> the v10 fields minus sampleRefs (empty table): pre-pS (bridge-resolve lift). -// * v8 blob -> {channelMode, marker, previewVelocity, voice bytes, masterGainLinear, selectionId, zones}: pre-GA (implicit mode). -// * v7 blob -> {channelMode, marker, previewVelocity, voiceCount, voiceMode, monoTrigger, selectionId, zones}: pre-FB1 (unity master gain). -// * v6 blob -> {channelMode, marker, previewVelocity, selectionId, zones}: pre-Phase-S (voice defaults). -// * v5 blob -> {channelMode, lastConsumedAssignGeneration, mid, selectionId, zones}: pre-S-VIEW-4 (no velocity). -// * v4 blob -> {channelMode, 0, mid, selectionId, zones}: pre-S8/S9 reader (no marker). -// * v3 blob -> {mono, 0, mid, selectionId, zones}: pre-S7 had no channel mode. -// * v2 blob -> {mono, 0, mid, "", zones}: an S5 instance had zones but no separate selection. -// * v1 blob -> {mono, 0, mid, id, one full-keyboard zone}: the S4 single-selection lift. -// * empty/unknown -> {mono, 0, mid, "", no zones}: EMPTY (the S10 silent empty state). +// * v10 blob -> the v11 fields minus instanceGuid (empty — minted on first publish). +// * v9 blob -> the v10 fields minus sampleRefs (empty table — bridge-resolve lift). +// * v8 blob -> {channelMode, marker, previewVelocity, voice bytes, masterGainLinear, selectionId, zones}: implicit mode. +// * v7 blob -> {channelMode, marker, previewVelocity, voiceCount, voiceMode, monoTrigger, selectionId, zones}: unity master gain. +// * v6 blob -> {channelMode, marker, previewVelocity, selectionId, zones}: voice defaults. +// * v5 blob -> {channelMode, lastConsumedAssignGeneration, mid, selectionId, zones}: no velocity byte. +// * v4 blob -> {channelMode, 0, mid, selectionId, zones}: no marker. +// * v3 blob -> {mono, 0, mid, selectionId, zones}: no channel mode. +// * v2 blob -> {mono, 0, mid, "", zones}: zones but no separate selection. +// * v1 blob -> {mono, 0, mid, id, one full-keyboard zone}: single-selection lift. +// * empty/unknown -> {mono, 0, mid, "", no zones}: EMPTY (the silent empty state). // -// WHY THE MARKER PERSISTS (S8 reader requirement). The last-consumed assignment generation is -// the disambiguator that stops a re-opened instance re-applying a stale assign_request the user -// already got and then manually changed away from: on re-open the instance re-reads the pending -// request, and only a generation STRICTLY GREATER than this stored marker re-applies (see -// bank_sync::consumeDecision). A fresh instance defaults to 0, so a genuinely new first assign -// (generation >= 1) still applies. It is the instrument's OWN state (D-B), never written to the -// bank — the extension owns the assign_request key; the instrument only tracks what it consumed. -// The preview-trigger velocity default (S-VIEW-4): a mid MIDI velocity. An older blob with no -// velocity byte lifts to this, and a fresh instance starts here — an audible-but-not-hot default. +// WHY THE MARKER PERSISTS. The last-consumed assignment generation stops a re-opened +// instance re-applying a stale assign_request the user already got and then manually +// changed away from: on re-open the instance re-reads the pending request, and only a +// generation STRICTLY GREATER than this stored marker re-applies (see +// bank_sync::consumeDecision). A fresh instance defaults to 0, so a genuinely new first +// assign (generation >= 1) still applies. It is the instrument's own state, never written +// to the bank — the extension owns the assign_request key; the instrument only tracks what +// it consumed. The preview-trigger velocity default is a mid MIDI velocity: an older blob +// with no velocity byte lifts to this, audible-but-not-hot. inline constexpr std::uint8_t kPreviewVelocityDefault = 64; struct ComponentState { std::string selectionId; // the single-capture pick; "" = no pick PerformanceMap map; // the opt-in zones; empty = no zones - ChannelMode channelMode = ChannelMode::Mono; // S7 decode mode; default mono (D-E) - // GA (v9): whether channelMode was DELIBERATELY set by the user (the editor toggle). - // While false (implicit), the shell auto-defaults the mode from the loaded capture's - // channel count on reload (stereo capture -> Stereo, mono -> Mono); once true, the - // user's choice is never fought. Pre-v9 blobs lift to false (implicit). + ChannelMode channelMode = ChannelMode::Mono; // decode mode; default mono + // Whether channelMode was DELIBERATELY set by the user (the editor toggle). While + // false (implicit), the shell auto-defaults the mode from the loaded capture's channel + // count on reload (stereo capture -> Stereo, mono -> Mono); once true, the user's + // choice is never fought. Pre-v9 blobs lift to false (implicit). bool channelModeExplicit = false; - std::int64_t lastConsumedAssignGeneration = 0; // S8/S9: last assign_request generation consumed - // S-VIEW-4 preview-trigger velocity (MIDI 1..127): a PER-INSTANCE performance choice (sibling - // of channelMode, NOT per-zone), persisted so the Sample-view preview button retains the user's - // chosen strike velocity across saves. Defaults to kPreviewVelocityDefault. + std::int64_t lastConsumedAssignGeneration = 0; // last assign_request generation consumed + // Preview-trigger velocity (MIDI 1..127): a PER-INSTANCE performance choice (sibling of + // channelMode, NOT per-zone), persisted so the Sample-view preview button retains the + // user's chosen strike velocity across saves. std::uint8_t previewVelocity = kPreviewVelocityDefault; - // Phase S voice system: PER-INSTANCE performance choices (siblings of channelMode, NOT - // per-zone). Defaults {16, Poly, Retrigger} reproduce pre-Phase-S behavior exactly, so an - // older blob lifting to these plays byte-identically. + // Voice system: PER-INSTANCE performance choices (siblings of channelMode, NOT + // per-zone). Defaults {16, Poly, Retrigger} reproduce pre-voice-system behavior + // exactly, so an older blob lifting to these plays byte-identically. int voiceCount = kDefaultVoiceCount; // polyphony bound, kMinVoiceCount..kMaxVoiceCount VoiceMode voiceMode = VoiceMode::Poly; // Poly | Mono (last-note-priority held stack) MonoTrigger monoTrigger = MonoTrigger::Retrigger; // mono takeover: Retrigger | Legato - // FB1 (Wave B) post-mixer master gain, stored LINEAR (0.0 = -inf/true silence; 1.0 = unity; - // up to ~15.849 = +24 dB — the master_gain module owns the dB taper). PER-INSTANCE output - // trim applied by process() AFTER the voice sum (engine + drain + preview) — never per - // voice, never a keymap fact. Default unity reproduces pre-FB1 output byte-identically, - // so an older blob lifting to 1.0 plays exactly as it did. + // Post-mixer master gain, stored LINEAR (0.0 = -inf/true silence; 1.0 = unity; up to + // ~15.849 = +24 dB — master_gain owns the dB taper). PER-INSTANCE output trim applied + // by process() AFTER the voice sum — never per voice, never a keymap fact. Default + // unity reproduces pre-master-gain output byte-identically. double masterGainLinear = 1.0; - // pS self-contained playback (v10): the instance-OWNED sample refs — path + intrinsics - // for every bank sample this instance plays (see the SampleRefs block above). setState - // decodes straight from these; NO bridge/extension read is required for playback. A - // pre-v10 blob lifts to an EMPTY table, and the shell falls back to the bridge-resolve - // path once (then re-saves self-contained). + // Self-contained playback: the instance-OWNED sample refs — path + intrinsics for every + // bank sample this instance plays (see the SampleRefs block above). setState decodes + // straight from these; NO bridge/extension read is required for playback. A pre-v10 + // blob lifts to an EMPTY table, and the shell falls back to the bridge-resolve path + // once (then re-saves self-contained). SampleRefs sampleRefs; - // pS-usage (v11): the minted per-instance identity the usage publisher keys its - // "rsusage_" ext-state record under (see sample_usage.h — the prune-protection - // seam). Persisted so the key is stable across sessions (records do not proliferate - // per reopen). Empty = never published (a fresh or pre-v11 instance); the processor - // mints one on first publish, and RE-mints when the publish plan detects this state - // was cloned onto another track (FX copy / track duplication — planUsagePublish). + // The minted per-instance identity the usage publisher keys its "rsusage_" + // ext-state record under (see sample_usage.h — the prune-protection seam). Persisted so + // the key is stable across sessions. Empty = never published (a fresh or pre-v11 + // instance); the processor mints one on first publish, and RE-mints when the publish + // plan detects this state was cloned onto another track (FX copy / track duplication). std::string instanceGuid; }; inline constexpr std::uint32_t kComponentStateVersion = 11; -// The pS-usage combined-state version (v10 + the minted instance guid, length-prefixed -// after the refs table). Mirrors the v10/v9/… series so the version branches in -// deserializeComponentState stay self-describing. +// v10 + the minted instance guid, length-prefixed after the refs table. Mirrors the +// v10/v9/… series so the version branches in deserializeComponentState stay self-describing. inline constexpr std::uint32_t kSelectionZonesRefsIdentityV11Version = 11; -// The pS self-contained combined-state version (v9 + the instance-owned sample-refs table). -// Wire shape of the refs block (inserted after the v9 explicit flag, before the selection -// id): 4-byte LE entry count, then per entry: 4-byte LE id length + id bytes, 4-byte LE -// path length + path bytes, 4-byte LE rootNote (two's-complement), 1 byte loop.hasLoop, -// 8-byte LE loop.start + 8-byte LE loop.end (two's-complement int64, written regardless of -// hasLoop), 4-byte LE channelCount (two's-complement), 4-byte LE displayName length + -// displayName bytes (display-only; the editor label's extension-absent fallback). +// v9 + the instance-owned sample-refs table. Wire shape of the refs block (inserted after +// the v9 explicit flag, before the selection id): 4-byte LE entry count, then per entry: +// 4-byte LE id length + id bytes, 4-byte LE path length + path bytes, 4-byte LE rootNote +// (two's-complement), 1 byte loop.hasLoop, 8-byte LE loop.start + loop.end (int64, written +// regardless of hasLoop), 4-byte LE channelCount (two's-complement), 4-byte LE displayName +// length + bytes (display-only; the editor label's extension-absent fallback). inline constexpr std::uint32_t kSelectionZonesRefsV10Version = 10; -// The pre-GA combined-state version (everything through the FB1 master gain, no channel-mode -// explicit flag). Retained so deserializeComponentState can lift a v8 blob to implicit mode. +// Everything through the master gain, no channel-mode explicit flag. Retained so +// deserializeComponentState can lift a v8 blob to implicit mode. inline constexpr std::uint32_t kSelectionZonesModeMarkerVelVoiceGainV8Version = 8; -// The GA combined-state version (v8 + the channel-mode-EXPLICIT flag). Mirrors the -// v8/v7/v6/… series so the v9-branch check in deserializeComponentState is self-describing. +// v8 + the channel-mode-EXPLICIT flag. Mirrors the v8/v7/v6/… series so the v9-branch check +// in deserializeComponentState is self-describing. inline constexpr std::uint32_t kSelectionZonesModeMarkerVelVoiceGainExplicitV9Version = 9; -// The pre-FB1 combined-state version (selection + zones + channel mode + consumed marker + -// preview velocity + voice system, no master gain). Retained so deserializeComponentState can -// lift a v7 blob to unity master gain. +// Selection + zones + channel mode + consumed marker + preview velocity + voice system, no +// master gain. Retained so deserializeComponentState can lift a v7 blob to unity master gain. inline constexpr std::uint32_t kSelectionZonesModeMarkerVelVoiceV7Version = 7; -// The pre-Phase-S combined-state version (selection + zones + channel mode + consumed marker + -// preview velocity, no voice-system fields). Retained so deserializeComponentState can lift a -// v6 blob to the voice defaults {16, Poly, Retrigger}. +// Selection + zones + channel mode + consumed marker + preview velocity, no voice-system +// fields. Retained so deserializeComponentState can lift a v6 blob to the voice defaults +// {16, Poly, Retrigger}. inline constexpr std::uint32_t kSelectionZonesModeMarkerVelV6Version = 6; -// The pre-S-VIEW-4 combined-state version (selection + zones + channel mode + consumed marker, no -// preview velocity). Retained so deserializeComponentState can lift a v5 blob to a mid velocity. +// Selection + zones + channel mode + consumed marker, no preview velocity. Retained so +// deserializeComponentState can lift a v5 blob to a mid velocity. inline constexpr std::uint32_t kSelectionZonesModeMarkerV5Version = 5; -// The pre-S8/S9-reader combined-state version (selection + zones + channel mode, no consumed -// marker). Retained so deserializeComponentState can lift a v4 blob to {mode, 0, sel, zones}. +// Selection + zones + channel mode, no consumed marker. Retained so +// deserializeComponentState can lift a v4 blob to {mode, 0, sel, zones}. inline constexpr std::uint32_t kSelectionZonesModeV4Version = 4; -// The pre-S7 combined-state version (selection + zones, no channel mode). Retained as a named -// constant so deserializeComponentState can lift a v3 blob to {mono, selection, zones}. +// Selection + zones, no channel mode. Retained so deserializeComponentState can lift a v3 +// blob to {mono, selection, zones}. inline constexpr std::uint32_t kSelectionZonesV3Version = 3; // The full instance state serialized to bytes for IBStream (getState). @@ -300,18 +266,16 @@ ComponentState deserializeComponentState(const std::vector& bytes, // --- Instance state (VST3 setState/getState) -------------------------------- // -// The instrument's OWN state is which bank sample it plays (D-B: the selection is a -// performance choice, held by the instrument, never written back to the bank). It is a -// single string id. serialize/deserialize keep the on-the-wire form explicit and -// versioned so a future Tier can extend it without breaking already-saved instances. +// The instrument's OWN state is which bank sample it plays (a performance choice, held by +// the instrument, never written back to the bank) — a single string id. serialize/ +// deserialize keep the on-the-wire form explicit and versioned so it can be extended +// without breaking already-saved instances. // -// Format (v1): a 4-byte little-endian version tag (== 1) followed by the id bytes. No -// length prefix is needed — the id runs to the end of the stream (the host tells us the -// byte count). deserializeSelection tolerates a truncated / wrong-version / empty blob -// by returning "" (no selection — under the S10 policy reversal an empty selection is -// SILENCE + the "pick a capture" empty state, not the bank's first sample), never -// throwing across the host boundary. Retained for the v1→v3 back-compat lift in -// deserializeComponentState; the processor's live state is the v3 ComponentState above. +// Format (v1): 4-byte LE version tag (== 1) followed by the id bytes — no length prefix +// needed, the id runs to end of stream. deserializeSelection tolerates a truncated/wrong- +// version/empty blob by returning "" (no selection is SILENCE + the "pick a capture" empty +// state, not the bank's first sample), never throwing across the host boundary. Retained +// for the v1->v3 back-compat lift in deserializeComponentState. inline constexpr std::uint32_t kSelectionStateVersion = 1; diff --git a/src/core/instrument/map/note_entry.cpp b/src/core/instrument/map/note_entry.cpp index 3a3e2a1..f2119a7 100644 --- a/src/core/instrument/map/note_entry.cpp +++ b/src/core/instrument/map/note_entry.cpp @@ -1,4 +1,4 @@ -// note_entry.cpp — see note_entry.h. PURE text->MIDI-note parse for the S12 numeric entry. +// note_entry.cpp — see note_entry.h. #include "core/instrument/map/note_entry.h" @@ -40,8 +40,8 @@ int letterSemitone(char up) { } } -// Parse a note name like "C4", "F#3", "Bb-1" (case-insensitive). MIDI 0 == C-1, 60 == C4 -// (the DAW convention the editor's noteLabel uses). Returns nullopt if it is not a note name. +// Parse a note name like "C4", "F#3", "Bb-1" (case-insensitive, DAW convention: +// MIDI 0 == C-1, 60 == C4). Returns nullopt if it is not a note name. std::optional parseNoteName(const std::string& s) { if (s.empty()) return std::nullopt; std::size_t i = 0; @@ -49,10 +49,8 @@ std::optional parseNoteName(const std::string& s) { if (base < 0) return std::nullopt; // not a letter -> not a note name ++i; int semitone = base; - // Optional accidental(s): # / b (or 's'/'f' are NOT accepted — keep it to the two glyphs). + // Optional accidental(s): # / b only (not 's'/'f'). while (i < s.size() && (s[i] == '#' || s[i] == 'b' || s[i] == 'B')) { - // A trailing 'b'/'B' could be a flat OR the start of nothing; here after a letter it is - // an accidental. '#' raises, 'b'/'B' lowers. if (s[i] == '#') ++semitone; else --semitone; ++i; diff --git a/src/core/instrument/map/note_entry.h b/src/core/instrument/map/note_entry.h index b1e909d..3ecb4d4 100644 --- a/src/core/instrument/map/note_entry.h +++ b/src/core/instrument/map/note_entry.h @@ -1,21 +1,9 @@ -// note_entry.h — PURE parse + clamp for the S12 direct numeric entry of a zone's -// low/high/root MIDI note. NO VST3, NO REAPER, NO SWELL/LICE types at the boundary. The -// mirror of the other pure editor helpers: the fiddly text->note parse lives here, unit- -// tested outside the DAW, while the editor shell hosts the text field (a SWELL edit control -// or a LICE text-entry idiom) and feeds the committed string here on Enter. +// note_entry — parse + clamp for direct numeric/note-name entry of a zone's low/high/root +// MIDI note (a drag on the keyboard strip can't hit a precise note reliably). // -// WHY IT EXISTS (S12). Low/high/root are draggable on the keyboard strip, but a drag can't -// hit a precise note reliably. This adds a typed field: the user clicks the field, types a -// value, and presses Enter; the shell hands the raw string here to parse into a clamped MIDI -// note [0,127] and commits via the same off-thread reload as every other edit. -// -// ACCEPTED FORMS (both, so a musician OR a MIDI-number user is served): -// * a plain decimal integer ("60", " 127 ", "+5") — the raw MIDI note number; and -// * a note name ("C4", "f#3", "Bb-1") — parsed to its MIDI number under the DAW's C4==60 -// convention (MIDI 0 == C-1, matching REAPER + the editor's noteLabel). -// A value out of [0,127] CLAMPS to the range (a typed 200 becomes 127) rather than -// rejecting — the least-surprising behavior for a nudge field. Unparseable input returns -// nullopt (the shell keeps the old value + may flash the field). +// Accepts a plain decimal integer ("60", "+5") or a note name ("C4", "f#3", "Bb-1", DAW +// convention: MIDI 0 == C-1, 60 == C4). Out-of-range CLAMPS to [0,127] rather than +// rejecting; unparseable input returns nullopt (shell keeps the old value). #pragma once @@ -24,10 +12,7 @@ namespace reasampler::instrument::map { -// Parse a typed low/high/root field into a clamped MIDI note [0,127]. Accepts a decimal -// integer OR a note name (see the header notes). Leading/trailing ASCII whitespace is -// ignored. An in-range parse returns the note; an out-of-range numeric or note value clamps -// into [0,127]; empty or unparseable input returns nullopt (no change). Pure — no host types. +// Leading/trailing whitespace ignored. Empty or unparseable input returns nullopt. std::optional parseNoteEntry(const std::string& text); } // namespace reasampler::instrument::map diff --git a/src/core/instrument/map/sample_map.cpp b/src/core/instrument/map/sample_map.cpp index 4f92b13..6206b1b 100644 --- a/src/core/instrument/map/sample_map.cpp +++ b/src/core/instrument/map/sample_map.cpp @@ -1,6 +1,5 @@ -// sample_map — pure implementation (the RESOLUTION half; the ComponentState codec -// lives in component_state_io.cpp since Q-W2v). See sample_map.h. NO VST3 / REAPER / -// SWELL / vendor includes; standard library + the pure bank_book / wav_codec / sampler_core. +// sample_map — pure implementation (the resolution half; the ComponentState codec lives +// in component_state_io.cpp). See sample_map.h. #include "core/instrument/map/sample_map.h" @@ -12,9 +11,8 @@ namespace reasampler::instrument::map { namespace { -// Translate a bank_model Sample's S2 intrinsics into the core's SampleLoop. The bank -// stores loop points as an optional LoopPoints (both-or-neither); the core wants a -// SampleLoop with an explicit hasLoop. Absent -> no loop. +// The bank stores loop points as an optional LoopPoints (both-or-neither); the core wants +// a SampleLoop with an explicit hasLoop. Absent -> no loop. SampleLoop loopFromSample(const Sample& s) { SampleLoop out; if (s.loop) { @@ -25,41 +23,33 @@ SampleLoop loopFromSample(const Sample& s) { return out; } -// A distilled SelectedSample from a bank_model Sample. rootNote defaults to middle C -// (60) when the bank left the intrinsic empty — Tier 0 still plays, just centered on -// C rather than a captured pitch (surfaced: an un-rooted sample plays unity at C4). +// rootNote defaults to middle C (60) when the bank left the intrinsic empty — an +// un-rooted sample plays unity at C4 rather than failing to play. SelectedSample distill(const Sample& s) { SelectedSample out; out.relativePath = s.relativePath; out.rootNote = s.rootNote ? *s.rootNote : 60; out.loop = loopFromSample(s); - out.channelCount = s.channelCount; // capture intrinsic; 0 = unknown (older entry) + out.channelCount = s.channelCount; // 0 = unknown (older entry) return out; } -// The ONE override-beats-intrinsic fold shared by the bank-side resolvePerformance and the -// refs-side resolvePerformanceFromRefs (pS): a zone's authored fields + the sample's -// intrinsics (already distilled — rootNote carries the middle-C default) -> ResolvedZone. -// Shared so the two resolution paths cannot drift. +// The ONE override-beats-intrinsic fold shared by resolvePerformance and +// resolvePerformanceFromRefs, so the two resolution paths cannot drift. ResolvedZone foldZone(const PerformanceZone& z, const SelectedSample& ref) { ResolvedZone rz; rz.relativePath = ref.relativePath; rz.lowNote = z.lowNote; rz.highNote = z.highNote; - // Effective root: override beats intrinsic (distill already defaulted an empty - // intrinsic to middle C). rz.rootNote = z.rootOverride ? *z.rootOverride : ref.rootNote; - // S-VIEW-6/S-VIEW-9: key tracking + the velocity->amp curve are instrument state — - // carried straight through and applied at play time. + // Key tracking + velocity curve are instrument state — carried straight through. rz.keyTrack = z.keyTrack; rz.velocityCurve = z.velocityCurve; - // Effective loop / start (S11): the per-zone override wins over the intrinsic; absent - // -> the intrinsic (loop) / frame 0 (start). The bank is never mutated (D-B). + // Per-zone override wins over the intrinsic; absent -> intrinsic (loop) / frame 0 + // (start). The bank is never mutated. rz.loop = z.loopOverride ? *z.loopOverride : ref.loop; rz.startFrame = z.startPoint ? *z.startPoint : 0; - // S15/S16 per-zone play params (SECONDS) carry through unchanged; buildZonedKeymap - // resolves them to frames. - rz.play = z.play; + rz.play = z.play; // SECONDS; buildZonedKeymap resolves to frames return rz; } @@ -67,22 +57,20 @@ ResolvedZone foldZone(const PerformanceZone& z, const SelectedSample& ref) { std::optional selectSample(const std::string& banksJson, const std::string& sampleId) { - // POLICY REVERSAL (S10): an empty selection is SILENCE, not the first sample. Short- - // circuit before parsing — no stored id resolves to nothing to play by design. + // An empty selection is SILENCE, not the first sample — by design. if (sampleId.empty()) return std::nullopt; if (banksJson.empty()) return std::nullopt; std::optional book = BankBook::deserialize(banksJson); if (!book) return std::nullopt; // malformed -> nothing to play (never throw) - // Search every bank (pool first, then named — banks() is ordinal order) for the - // stored id. A sample lives in exactly one bank, so first hit wins. + // Search every bank (ordinal order) for the stored id; a sample lives in exactly + // one bank, so first hit wins. for (const Bank& b : book->banks()) { if (const Sample* s = b.index.query(sampleId)) { return distill(*s); } } - // A stale stored id (no longer resolves) is SILENCE, not a substituted first sample: - // the editor reflects the missing pick with its empty state rather than masking it. + // A stale stored id is SILENCE too — the editor's empty state, not a substitution. return std::nullopt; } @@ -92,7 +80,7 @@ ChannelMode channelModeFor(int channelCount, ChannelMode current, bool isExplici return channelCount >= 2 ? ChannelMode::Stereo : ChannelMode::Mono; } -// --- Instance-owned sample references (pS self-contained playback) ------------- +// --- Instance-owned sample references (self-contained playback) ------------- const SelectedSample* findRef(const SampleRefs& refs, const std::string& sampleId) { if (sampleId.empty()) return nullptr; @@ -133,7 +121,7 @@ void refreshRefsFromBank(SampleRefs& refs, const std::string& banksJson, for (SampleRefEntry& e : refs) { if (e.sampleId == id) { e.ref = distilled; - e.displayName = found->displayName; // rename sync rides the same refresh + e.displayName = found->displayName; // rename sync updated = true; break; } @@ -233,22 +221,18 @@ DecodedZonePcm decodeChannels(const std::vector& interleaved, if (sampleRate <= 0) return out; // safe early-return; caller supplied an invalid rate out.sampleRate = sampleRate; if (mode == ChannelMode::Mono) { - // MONO mode: the existing downmix policy (average all source channels), one channel out. out.monoFrames = downmixToMono(interleaved, sourceChannels); return out; // framesR stays empty } - // STEREO mode: channel 0 = source channel 0; channel 1 = source channel 1, or channel 0 - // duplicated when the source is mono (dual-mono, centered). extractChannel clamps the - // out-of-range channel request to the last channel, so a mono source yields L == R. + // extractChannel clamps out-of-range, so a mono source yields L == R (dual-mono). out.monoFrames = extractChannel(interleaved, sourceChannels, 0); out.framesR = extractChannel(interleaved, sourceChannels, 1); return out; } ZonePlayParams resolvePlay(const ZonePlaySeconds& stored, int sampleRate) { - // seconds -> frames at the LIVE rate (round-to-nearest). Wall-clock quantities (AHDSR A/H/D/R, - // pitch env A/D) resolve here; source-timeline quantities (trigger %-length + fades) carry - // through untouched — they are already source frames / fractions. Non-time fields pass as-is. + // seconds -> frames at the LIVE rate; source-timeline quantities (trigger %-length + + // fades) carry through untouched, already frames/fractions. assert(sampleRate > 0 && "resolvePlay: sampleRate must be > 0 (programming error)"); const double sr = sampleRate > 0 ? static_cast(sampleRate) : 1.0; // 1.0 avoids div-by-zero; assert fires first const auto secToFrames = [sr](double sec) { @@ -304,8 +288,7 @@ ResolvedPerformance resolvePerformance(const std::string& banksJson, if (!book) return out; // malformed -> nothing (never throw) for (const PerformanceZone& z : map.zones) { - // Look the id up across every bank (pool + named) — a sample lives in exactly - // one bank, so first hit wins. + // A sample lives in exactly one bank, so first hit wins. const Sample* found = nullptr; for (const Bank& b : book->banks()) { if (const Sample* s = b.index.query(z.sampleId)) { @@ -314,12 +297,11 @@ ResolvedPerformance resolvePerformance(const std::string& banksJson, } } if (!found) { - // STALE-ID POLICY: drop the zone cleanly, report the id (editor can prune). - out.droppedSampleIds.push_back(z.sampleId); + out.droppedSampleIds.push_back(z.sampleId); // stale: drop, report continue; } - // Distill the bank Sample to the same intrinsics shape the refs table carries, then - // run the SHARED fold — so the bank path and the refs path resolve identically. + // Distill to the same intrinsics shape the refs table carries, then run the SHARED + // fold — so the bank path and refs path resolve identically. out.zones.push_back(foldZone(z, distill(*found))); } return out; @@ -332,8 +314,7 @@ ResolvedPerformance resolvePerformanceFromRefs(const SampleRefs& refs, if (const SelectedSample* r = findRef(refs, z.sampleId)) { out.zones.push_back(foldZone(z, *r)); } else { - // No ref for this id (never copied, or a pre-v10 blob not yet lifted): drop the - // zone cleanly + report — the same shape as the bank path's stale-id policy. + // No ref for this id: drop + report, same shape as the bank path's stale-id policy. out.droppedSampleIds.push_back(z.sampleId); } } diff --git a/src/core/instrument/map/sample_map.h b/src/core/instrument/map/sample_map.h index 3f6d5a8..63edeab 100644 --- a/src/core/instrument/map/sample_map.h +++ b/src/core/instrument/map/sample_map.h @@ -1,22 +1,10 @@ #pragma once -// sample_map — PURE mapping logic for the S4 Tier-0 instrument: turn the live -// "reasampler" bank ext-state + a decoded WAV into the plain data the sampler core -// plays, and (de)serialize the instance's selected-sample choice for VST3 component -// state. NO VST3, NO REAPER, NO SWELL, NO vendor/ includes at the boundary — the -// mirror of capture_paths / wav_codec / bridge_marshal splitting the fiddly, testable -// arithmetic out of a host-facing shell. -// -// WHY IT EXISTS (S4 seams). The instrument reads the bank over the live-state seam -// (the "banks" ext-state blob) and the audio over the file seam (the on-disk WAV). -// Both of those raw inputs cross the bridge/file boundary in the shell; everything -// after — parse the bank with the SHARED bank_model/bank_book JSON path (NOT a second -// parser; the S1 spike's string-scan reader is retired), pick the selected sample, -// downmix its decoded PCM to the core's mono contract, and build the Tier-0 chromatic -// Keymap — is pure and unit-tested here. -// -// It links bank_book (the shared BankBook::deserialize) and wav_codec (the shared -// 32-bit-float WAV parse — no third WAV reader) and sampler_core (the Keymap / -// SampleData it produces). All three are pure; this stays pure. +// sample_map — turns the live "reasampler" bank ext-state + a decoded WAV into the plain +// data the sampler core plays, and (de)serializes the instance's zone/selection state. +// The bank is read over the live-state seam, audio over the file seam; both raw inputs +// cross the bridge/file boundary in the shell, everything after (bank parse via the shared +// bank_book JSON path, sample pick, mono downmix, keymap build) is pure and unit-tested +// here. Links bank_book, wav_codec, and sampler_core (all pure). #include #include @@ -29,77 +17,56 @@ namespace reasampler::instrument::map { -// Cross-subsystem deps by their real namespace homes (Q-W2v: sample_map now lives in -// instrument::map; the engine family stays in flat `reasampler` until its own wave). using audio::AudioSample; using instrument::engine::VelocityCurve; using instrument::engine::VelocityPoint; -// The bank sample this instance is bound to, distilled from the live "banks" blob: -// the project-relative WAV path the file seam must resolve+decode, plus the S2 bank -// intrinsics the core repitches / loops by. A pure value — no host, no PCM yet. +// The bank sample this instance is bound to, distilled from the live "banks" blob: the +// project-relative WAV path the file seam resolves+decodes, plus the bank intrinsics the +// core repitches/loops by. A pure value — no host, no PCM yet. struct SelectedSample { - std::string relativePath; // project-relative; the shell resolves it (M4 convention) - int rootNote = 60; // S2 intrinsic; defaults to middle C when the bank left it empty - SampleLoop loop; // S2 intrinsic; hasLoop=false when the bank left it empty - int channelCount = 0; // bank intrinsic (capture channel count); 0 = unknown (older - // bank entries) — the GA channel-mode auto-default skips it + std::string relativePath; // project-relative; the shell resolves it + int rootNote = 60; // defaults to middle C when the bank left it empty + SampleLoop loop; // hasLoop=false when the bank left it empty + int channelCount = 0; // capture channel count; 0 = unknown (older bank entries) — + // the GA channel-mode auto-default skips it }; -// Resolve the bound sample from the live bank blob. `banksJson` is the raw "banks" -// ext-state value the bridge read (may be empty / malformed — an unsaved or pre-bank -// project). `sampleId` is this instance's stored selection. +// `banksJson` is the raw "banks" ext-state value the bridge read (may be empty/malformed — +// an unsaved or pre-bank project); `sampleId` is this instance's stored selection. // -// Precedence, all pure: -// * empty / malformed banksJson -> nullopt (nothing to play) -// * sampleId empty -> nullopt (NO selection -> silence) -// * sampleId names a sample in ANY bank -> that sample (searched pool + named) -// * sampleId set but not found (stale) -> nullopt (the sample was deleted/moved; -// the editor returns to the empty state) -// -// POLICY REVERSAL (S10, 2026-07-26 — supersedes the S4 first-sample fallback). A fresh -// instance with no stored selection resolves to nullopt (SILENCE), NOT the bank's first -// sample: the metric is time-to-first-note via an explicit pick, and a mystery auto-play -// of sample #1 was the anti-pattern. A stale stored id (no longer resolves) ALSO returns -// nullopt rather than silently substituting a different sample — the editor reflects the -// missing selection with its "pick a capture" empty state instead of masking it. +// Precedence: empty/malformed banksJson -> nullopt. Empty sampleId -> nullopt (no selection +// is SILENCE, not the bank's first sample — deliberate: the metric is time-to-first-note via +// an explicit pick, and mystery auto-play of sample #1 was the anti-pattern). sampleId found +// in any bank -> that sample. sampleId set but not found (stale) -> nullopt, same as no +// selection — the editor shows its "pick a capture" empty state rather than masking it. std::optional selectSample(const std::string& banksJson, const std::string& sampleId); -// GA auto-default rule (pure, tested): given the capture's requested channel count, the -// instance's current mode, and whether the user has explicitly toggled the mode, return -// the mode to apply. Explicit choice is never overridden. An unknown channelCount (0) -// leaves the current mode unchanged. Used by reloadInstrument in the single-capture path. -// * isExplicit == true -> current (user's choice stands) -// * channelCount == 0 -> current (unknown, skip) -// * channelCount >= 2 -> Stereo -// * channelCount == 1 -> Mono +// Auto-default rule: given the capture's channel count, current mode, and whether the user +// explicitly toggled it, return the mode to apply. Explicit choice is never overridden; +// channelCount == 0 (unknown) leaves the current mode; >= 2 -> Stereo; == 1 -> Mono. ChannelMode channelModeFor(int channelCount, ChannelMode current, bool isExplicit); -// --- Instance-owned sample references (pS self-contained playback) ------------- +// --- Instance-owned sample references (self-contained playback) ------------- // -// THE ARCHITECTURE CORRECTION: the instrument must never go silent because the extension's -// ext-state has not parsed yet (or the extension is absent). So the instance persists, in -// its OWN component state, a small table of everything it needs to PLAY each referenced -// bank sample: the project-relative WAV path + the decode intrinsics (root note, loop, -// channel count) — exactly a SelectedSample, keyed by the bank sample id. On load the -// shell decodes straight from these refs; the bank blob is a BROWSER SOURCE that also -// refreshes this table opportunistically when readable (recapture/root edits stay live), -// never a runtime lifeline. +// The instrument must never go silent just because the extension's ext-state hasn't parsed +// yet (or the extension is absent). So the instance persists, in its OWN component state, a +// table of everything needed to PLAY each referenced bank sample: path + decode intrinsics +// (root, loop, channel count), keyed by bank sample id. The shell decodes straight from +// these refs; the bank blob is a browser source that refreshes the table opportunistically +// when readable, never a runtime lifeline. // -// POLICY (follows from ownership): a sample deleted from the BANK no longer silences an -// instance that carries its ref — the instance keeps playing while the FILE exists (normal -// sampler behavior; prune deleting the file yields the defined no-play). This deliberately -// supersedes the S10 stale-id-silence rule, which was an artifact of bank-side resolution. -struct PerformanceMap; // defined below (Tier 1); referencedSampleIds spans both tiers +// Consequence: a sample deleted from the bank no longer silences an instance that carries +// its ref — it keeps playing while the file exists (normal sampler behavior; prune deleting +// the file yields the defined no-play). +struct PerformanceMap; // defined below; referencedSampleIds spans both selection + zones struct SampleRefEntry { std::string sampleId; // the bank sample id this ref was copied from (the seam key) SelectedSample ref; // path + intrinsics, sufficient to decode + play without a bank - // The sample's bank display name at copy time — DISPLAY ONLY (the editor's label falls - // back to it when the bank snapshot is unavailable, mirroring the waveform/loop ref - // fallback); never consulted by resolution. Empty for a table written before the field - // existed in-session (it back-fills on the next bank refresh). + // Bank display name at copy time — DISPLAY ONLY (editor label fallback when the bank + // snapshot is unavailable); never consulted by resolution. std::string displayName; }; using SampleRefs = std::vector; @@ -112,43 +79,32 @@ const SelectedSample* findRef(const SampleRefs& refs, const std::string& sampleI std::vector referencedSampleIds(const std::string& selectionId, const PerformanceMap& map); -// Upsert a ref for each id in `ids` that resolves in the live bank blob (the same -// distillation selectSample performs), copying the bank display name alongside the decode -// intrinsics. A miss leaves any existing entry untouched — the instance owns its copy; a -// bank deletion never strips a ref. Empty/malformed blob -> no-op. +// Upsert a ref for each id in `ids` that resolves in the live bank blob, copying the display +// name alongside the decode intrinsics. A miss leaves any existing entry untouched — the +// instance owns its copy; a bank deletion never strips a ref. Empty/malformed blob -> no-op. void refreshRefsFromBank(SampleRefs& refs, const std::string& banksJson, const std::vector& ids); -// The pre-v10 LEGACY-LIFT terminating decision (pure, so the no-churn rule is provable -// without a host): can a refs lift MAKE PROGRESS against this bank blob for the ids the -// instance references? -// * Retry — the blob is absent/empty/unparseable: not readable YET, keep retrying (the -// project's ext-state may simply not have parsed). -// * Lift — the blob parses and at least one id resolves: a lift copies a ref in (the -// refs table then goes non-empty and the lift never re-fires). -// * Stale — the blob parses and NO id resolves (an empty `ids` included): the ids are -// PROVABLY stale — the bank is readable and does not know them — so there is nothing -// to lift, ever. The shell latches this and stops retrying (no per-tick churn). +// Legacy-lift terminating decision: can a refs lift make progress against this bank blob +// for the ids the instance references? +// * Retry — blob absent/empty/unparseable: not readable yet, keep retrying. +// * Lift — blob parses and at least one id resolves: copy a ref in (never re-fires once +// the refs table is non-empty). +// * Stale — blob parses and no id resolves: provably stale, nothing to lift, ever — the +// shell latches this and stops retrying (no per-tick churn). enum class LegacyLiftDecision { Retry, Lift, Stale }; LegacyLiftDecision legacyLiftDecision(const std::optional& banksJson, const std::vector& ids); -// Keep only the entries whose id is in `ids` (getState hygiene: the persisted table tracks -// exactly what the instance currently plays, so it cannot grow with browsing history). +// Keep only the entries whose id is in `ids` (getState hygiene: the persisted table cannot +// grow with browsing history). void retainRefs(SampleRefs& refs, const std::vector& ids); -// One entry in the capture browser's card list: the stable id + display name plus the S2 -// intrinsics + bank the browser draws as a card (peak thumbnail + name + root/key badge, -// filterable by bank). Peaks are NOT here — they are computed shell-side from the decoded -// PCM (the `Sample` metadata carries no envelope; see reasampler_editor's thumbnail cache, -// the mirror of bank_panel::thumbnailFor). This carries only what the bank blob already -// holds: the metadata the card badge + bank filter need. Pure projection over the shared -// parse — the UI never parses JSON itself. -// -// - rootNote: the S2 rootNote intrinsic when the bank set it (nullopt otherwise — the -// badge shows "root: —" / no root, never a guessed value). -// - key: the optional human musical key label ("F#m"), when the bank set it. -// - bankId: the id of the bank this sample lives in (the bank filter matches on it). +// One entry in the capture browser's card list: stable id + display name + intrinsics + +// bank, for a card (peak thumbnail + name + root/key badge, filterable by bank). Peaks are +// NOT here — computed shell-side from the decoded PCM (reasampler_editor's thumbnail +// cache). rootNote is nullopt when the bank left it empty (badge shows no root, never a +// guessed value). Pure projection over the shared parse — the UI never parses JSON itself. struct SampleChoice { std::string id; std::string displayName; @@ -167,35 +123,27 @@ struct BankChoice { }; std::vector listBanks(const std::string& banksJson); -// Downmix interleaved float frames (the shape wav_codec's extractFloatFrames yields: -// [f0c0,f0c1,...,f1c0,...]) to the core's MONO contract by AVERAGING channels per -// frame. `channelCount` is the interleave stride (>= 1). CHANNEL POLICY (Tier 0, -// documented + surfaced): the S3 core is mono-per-sample by design; bank WAVs preserve -// their source channel count, so a stereo (or N-channel) capture is folded to a single -// mono stream here by an equal-weight average. Averaging (not "take L", not summing) is -// the least-surprising, no-clip default — a centered mono source stays unity, and a -// hard-panned source is attenuated rather than silenced or doubled. Empty / zero-stride -// in -> empty out. Pure. +// Downmix interleaved float frames ([f0c0,f0c1,...,f1c0,...]) to the core's MONO contract +// by AVERAGING channels per frame (`channelCount` is the interleave stride, >= 1) — not +// "take L", not summing: a centered mono source stays unity, a hard-panned source is +// attenuated rather than silenced or doubled. Empty/zero-stride in -> empty out. Pure. std::vector downmixToMono(const std::vector& interleaved, int channelCount); -// Deinterleave one channel (`which`, 0-based) out of interleaved frames. `channelCount` is -// the interleave stride (>= 1); `which` is clamped to a valid channel (a request past the -// source's last channel reads the last channel, so a mono source asked for channel 1 yields -// channel 0 again — the dual-mono building block). Empty / zero-stride in -> empty out. Pure. +// Deinterleave one channel (`which`, 0-based). `which` clamps to a valid channel (a request +// past the last channel reads the last channel, so a mono source asked for channel 1 yields +// channel 0 — the dual-mono building block). Empty/zero-stride in -> empty out. Pure. std::vector extractChannel(const std::vector& interleaved, int channelCount, int which); // --- Stored (wall-clock SECONDS) per-zone play params ------------------------- // -// DOMAIN SPLIT (S12 remediation — Daniel's ruling: no hardcoded sample rate in the program). -// The instrument stores and edits WALL-CLOCK performance times as SECONDS, rate-free; the -// engine (sampler_core's ZonePlayParams, on SampleData) receives FRAMES resolved from the -// LIVE sample rate at keymap build. AHDSR (A/H/D/S/R) and the AD pitch envelope (attack/decay) -// are wall-clock — the voice advances them once per OUTPUT frame — so they live here in seconds. -// Quantities anchored to the source file's timeline (start point, loop points, Trigger %-length -// and its fades — the fades anchor to the source-frame read offset, PLAN.md §S15) stay in source -// frames / fractions and are carried through unchanged (TriggerParams is reused verbatim). +// Daniel's standing ruling: no hardcoded sample rate anywhere in the program. The +// instrument stores/edits wall-clock performance times (AHDSR A/H/D/R, pitch-env A/D) as +// SECONDS, rate-free; the engine receives FRAMES resolved from the LIVE sample rate at +// keymap build. Quantities anchored to the source file's timeline (start point, loop +// points, Trigger %-length + fades) stay in source frames/fractions, carried through +// unchanged (TriggerParams reused verbatim). // // The stored AHDSR times (seconds). sustainLevel is dimensionless (0..1), not a time. struct AdsrSeconds { @@ -214,61 +162,51 @@ struct PitchEnvSeconds { double peakSemitones = 0.0; // signed depth at the peak }; -// The stored per-zone play bundle: wall-clock times in SECONDS, source-timeline quantities in -// frames/fractions (TriggerParams). This is the instrument-owned (D-B), serialized, editor-facing -// representation — distinct from sampler_core's engine-facing ZonePlayParams (frames). The keymap -// builders resolve this to a frame-domain ZonePlayParams against the live sample rate. +// The stored per-zone play bundle: wall-clock times in SECONDS, source-timeline quantities +// in frames/fractions (TriggerParams). Instrument-owned, serialized, editor-facing — +// distinct from sampler_core's engine-facing ZonePlayParams (frames). struct ZonePlaySeconds { PlayMode playMode = PlayMode::Gate; AdsrSeconds adsr; // Gate: AHDSR (seconds) TriggerParams trigger; // Trigger: %-length + fades (source frames) - PitchEngine pitchEngine = kDefaultPitchEngine; // product default: Preserve (S16-F1) + PitchEngine pitchEngine = kDefaultPitchEngine; // product default: Preserve PitchEnvSeconds pitchEnv; // AD pitch modulation (seconds), off by default }; // Resolve a stored seconds bundle to the engine's frame-domain ZonePlayParams against a live -// sample rate (frames = round(seconds * rate)). Source-timeline fields (trigger, engine, mode, -// peak, enabled) carry through unchanged. `sampleRate` must be > 0 (the caller guards this). +// sample rate (frames = round(seconds * rate)). Source-timeline fields carry through +// unchanged. `sampleRate` must be > 0 (the caller guards this). ZonePlayParams resolvePlay(const ZonePlaySeconds& stored, int sampleRate); // Build the Tier-0 chromatic keymap for one decoded sample: one zone spanning the whole -// keyboard, repitched from `rootNote`, looped per `loop`. The single-sample degenerate case -// (Keymap::singleSampleChromatic) with the S2 intrinsics threaded in. `frames` is channel 0 -// (mono, or L); `framesR` is channel 1 (R) — pass EMPTY for a mono sample (the default), -// which yields a mono SampleData byte-identical to the pre-S7 build. A `framesR` whose length -// mismatches `frames` is dropped (SampleData::channelCount() falls back to mono), so a bad -// pair never half-plays. `sampleRate` is the WAV's rate. -// `play` carries the S15/S16 per-zone play params (SECONDS) for the single-capture path; it -// defaults to the PRODUCT defaults (Gate + tier-0 AHDSR seconds + Preserve engine, S16-F1) so a -// picked single capture plays under the same default engine as a zone would. This function -// resolves the wall-clock seconds to frames against `sampleRate` before stamping the SampleData. +// keyboard, repitched from `rootNote`, looped per `loop` (Keymap::singleSampleChromatic). +// `frames` is channel 0 (mono, or L); `framesR` is channel 1 (R) — pass EMPTY for a mono +// sample. A `framesR` whose length mismatches `frames` is dropped (falls back to mono), so a +// bad pair never half-plays. `sampleRate` is the WAV's rate. `play` carries the per-zone play +// params (SECONDS); defaults to the product defaults (Gate + tier-0 AHDSR + Preserve) so a +// picked single capture plays under the same default engine as a zone would. Resolves the +// wall-clock seconds to frames against `sampleRate` before stamping the SampleData. Keymap buildTier0Keymap(std::vector frames, int sampleRate, int rootNote, const SampleLoop& loop, std::vector framesR = {}, const ZonePlaySeconds& play = ZonePlaySeconds{}); -// --- Performance map (Tier 1, D-B: the instrument's OWN state) --------------- +// --- Performance map (the instrument's OWN state) --------------- // // The performance map is the keymap the user authors IN the instrument: several bank -// samples zoned across the keyboard, each with a key range and a root note. It is a -// PERFORMANCE CHOICE (D-B), so it lives in the instrument (VST3 component state), never -// written back to the bank. Root note per zone is SEEDED from the S2 bank intrinsic but -// OVERRIDABLE here — the override lives on the zone, never on `Sample`. -// -// Pure value type: it names bank samples by id (the stable seam key) and holds no PCM. -// The shell resolves each id's WAV over the file seam and decodes it; the pure zone-build -// stitches the decoded frames + this map into a sampler_core Keymap. +// samples zoned across the keyboard, each with a key range and a root note. A performance +// choice, so it lives in the instrument (VST3 component state), never written back to the +// bank. Pure value type: names bank samples by id (the stable seam key), holds no PCM — the +// shell resolves+decodes each id's WAV, and the pure zone-build stitches the decoded frames +// + this map into a sampler_core Keymap. -// One authored zone: a bank sample mapped to an inclusive [lowNote, highNote] key range, -// with an optional root-note override. rootOverride absent -> repitch from the bank -// sample's own S2 rootNote intrinsic (or middle C when the bank left it empty). -// -// S11 loop/start overrides (instrument-owned, D-B — mirror of rootOverride): the sustain -// loop and the initial read position are FACTS about the file (S2 bank intrinsics), but the -// instrument may override them per zone WITHOUT writing back to the bank. loopOverride wins -// over the bank's S2 loop intrinsic when set; startPoint sets the voice's initial read frame -// (absent -> frame 0). Both are seeded from the bank intrinsic in the editor and stored here; -// resolvePerformance folds override-beats-intrinsic into the effective ResolvedZone. +// One authored zone: a bank sample mapped to an inclusive [lowNote, highNote] key range. +// rootOverride absent -> repitch from the bank sample's own rootNote intrinsic (or middle C +// when empty). loopOverride/startPoint mirror rootOverride: the sustain loop and initial +// read position are facts about the file, but the instrument may override them per zone +// without writing back to the bank (loopOverride wins when set; startPoint sets the voice's +// initial read frame, absent -> 0). resolvePerformance folds override-beats-intrinsic into +// the effective ResolvedZone. struct PerformanceZone { std::string sampleId; // bank sample id this zone plays int lowNote = 0; // inclusive @@ -277,146 +215,125 @@ struct PerformanceZone { std::optional loopOverride; // instrument-owned sustain loop; absent -> bank intrinsic std::optional startPoint; // instrument-owned initial read frame; absent -> 0 - // S-VIEW-6 key-tracking scalar (instrument-owned, D-B — mirror of rootOverride): how far - // playback pitch tracks the keyboard around the root. 1.0 (100%) is standard 12-tone-ET (the - // DEFAULT; a pre-S-VIEW-6 blob with no keyTrack tail lifts to exactly 1.0, so already-saved - // instances are bit-identical); 0.0 = no tracking (every key plays root pitch); 2.0 = double. - // NOT flag-gated — always present in the CURRENT payload (v6). Carried through to KeyZone by - // resolvePerformance and applied in keyTrackedRatio inside BOTH repitch engines. + // Key-tracking scalar: how far playback pitch tracks the keyboard around the root. 1.0 + // (100%, standard 12-tone-ET) is the default — a blob predating this field lifts to + // exactly 1.0, so already-saved instances are bit-identical. 0.0 = no tracking (every + // key plays root pitch); 2.0 = double. Applied in keyTrackedRatio inside both repitch + // engines. double keyTrack = 1.0; - // S-VIEW-9 velocity->amp transfer curve (instrument-owned, D-B — mirror of keyTrack): maps the - // note-on MIDI velocity (0..127) to the voice's amp gain, replacing the fixed linear velocity/127. - // A per-sound performance characteristic, so it varies PER ZONE. DEFAULT = flat y=1 (R10-F1 - // Option A, Daniel-approved): every velocity plays at unity. This is a DELIBERATE, non-back-compat - // behavior change — a pre-S-VIEW-9 blob (no velocityCurve field) lifts to flat y=1, so an - // already-saved zone's soft hits play LOUDER than under the old linear map. Intended; do NOT - // preserve the linear response. Carried to KeyZone by resolvePerformance, eval'd in Voice::start. - // Sequenced on the zones-payload axis AFTER keyTrack (payload v6 -> v7). + // Velocity->amp transfer curve: maps note-on MIDI velocity (0..127) to voice amp gain, + // replacing the old fixed linear velocity/127. Per-zone. Default = flat y=1 (Daniel- + // approved): every velocity plays at unity. DELIBERATE non-back-compat behavior change — + // a blob predating this field lifts to flat y=1, so an already-saved zone's soft hits + // play LOUDER than under the old linear map. Do NOT preserve the linear response. Eval'd + // in Voice::start. VelocityCurve velocityCurve = VelocityCurve::flat(); - // S15/S16 per-zone play parameters (play mode + AHDSR + Trigger %-length/fades; pitch - // engine + AD pitch envelope). Instrument-owned (D-B), never a bank fact — mirror of the - // loop/start overrides. Wall-clock times are stored in SECONDS (rate-free); the keymap build - // resolves them to frames at the live sample rate. Defaults to the PRODUCT defaults for a NEW - // zone: Gate play mode, tier-0 AHDSR seconds (0.003 attack / 0.060 release), hold 0, no fades, - // PRESERVE pitch engine (S16-F1), pitch env off. An older zone-payload blob (no S15/S16 tail) - // lifts to exactly these defaults on read (see the PAYLOAD versioning). + // Per-zone play parameters (play mode + AHDSR + Trigger %-length/fades; pitch engine + + // AD pitch envelope). Instrument-owned, never a bank fact. Wall-clock times stored in + // SECONDS (rate-free); keymap build resolves to frames at the live sample rate. Defaults + // for a NEW zone: Gate, tier-0 AHDSR seconds (0.003 attack / 0.060 release), hold 0, no + // fades, Preserve pitch engine, pitch env off. An older zone blob lacking this tail lifts + // to exactly these defaults on read. ZonePlaySeconds play; }; // The instrument's performance map: an ordered list of zones. Order is authoritative for -// overlap resolution (OVERLAP POLICY: first zone in order wins, mirroring the S3 core's -// first-match Keymap::resolve — overlaps are neither rejected nor clamped, the earlier -// zone simply takes the contested keys; documented, deterministic). +// overlap resolution — first zone in order wins (mirrors the core's first-match +// Keymap::resolve); overlaps are neither rejected nor clamped, deterministic by construction. struct PerformanceMap { std::vector zones; bool empty() const { return zones.empty(); } }; -// Single-capture ("Sample face") zone-lifecycle reconcile — the zone-bleed fix (issue 3a). +// Single-capture ("Sample face") zone-lifecycle reconcile — the zone-bleed fix. // // The Sample face materializes ONE full-range [0,127] zone for the loaded sample on first -// control edit (ensureSampleZone). Loading a different sample used to change only the -// selection id, leaving the previous sample's full-range zone in the map — and since zone -// resolution is FIRST-MATCH in order, that stale zone shadowed every later one forever: the -// engine kept playing the old sample while the editor drew the new one's zone (matched by -// sampleId, order-blind). This function is called at every selection-change site so the zone -// the editor draws is the zone the engine plays. +// control edit. Loading a different sample used to change only the selection id, leaving +// the previous sample's full-range zone in the map — and since zone resolution is +// first-match in order, that stale zone shadowed every later one forever: the engine kept +// playing the old sample while the editor drew the new one's zone. This function is called +// at every selection-change site so the zone the editor draws is the zone the engine plays. // -// Rules (pure, order-preserving where it matters): -// * empty `selectedId` or empty map -> untouched, false. -// * ANY zone with an authored key range (not the full [0,127]) -> the map is Zone-view -// authorship; first-match order is load-bearing there — untouched, false. The Sample -// face never creates a narrow zone, so a narrow zone proves deliberate multi-zone intent. -// * else (every zone full-range — the map is purely Sample-face-shaped): keep only the -// first zone bound to `selectedId` (the selection's own params are not reset); drop -// the rest. A selection with no zone yet empties the map (the shell then plays the -// selection via the Tier-0 fast path with product defaults). +// Rules (order-preserving where it matters): +// * empty `selectedId` or empty map -> untouched, false. +// * ANY zone with an authored key range (not full [0,127]) -> Zone-view authorship, +// first-match order is load-bearing there — untouched, false (the Sample face never +// creates a narrow zone, so a narrow zone proves deliberate multi-zone intent). +// * else (every zone full-range) -> keep only the first zone bound to `selectedId` +// (params preserved); drop the rest. A selection with no zone yet empties the map. // Returns true iff the map changed (the caller republishes + reloads on true). bool reconcileSingleCaptureZones(PerformanceMap& map, const std::string& selectedId); -// One resolved zone ready for the shell to decode + the pure build to stitch: the bank -// sample's project-relative WAV path (file seam), the EFFECTIVE root note (override beats -// bank intrinsic beats middle-C default), the loop intrinsic, and the key range. Distinct -// from PerformanceZone (which names an id) — this is the id resolved against the live bank. +// One resolved zone ready for the shell to decode + the pure build to stitch: project- +// relative WAV path (file seam), effective root note (override beats bank intrinsic beats +// middle-C default), loop intrinsic, key range. Distinct from PerformanceZone (which names +// an id) — this is the id resolved against the live bank. struct ResolvedZone { std::string relativePath; // project-relative; the shell resolves + decodes it int lowNote = 0; int highNote = 127; int rootNote = 60; // effective: override, else bank intrinsic, else 60 - double keyTrack = 1.0; // S-VIEW-6 key-tracking scalar, carried from PerformanceZone (1.0 = 100% ET) - VelocityCurve velocityCurve = VelocityCurve::flat(); // S-VIEW-9 velocity->amp curve, carried from PerformanceZone - SampleLoop loop; // effective: loopOverride, else bank S2 intrinsic (S11) - std::int64_t startFrame = 0; // effective initial read frame: startPoint, else 0 (S11) + double keyTrack = 1.0; // carried from PerformanceZone (1.0 = 100% ET) + VelocityCurve velocityCurve = VelocityCurve::flat(); // carried from PerformanceZone + SampleLoop loop; // effective: loopOverride, else bank intrinsic + std::int64_t startFrame = 0; // effective initial read frame: startPoint, else 0 ZonePlaySeconds play; // S15/S16 per-zone play params (SECONDS; resolved to frames at build) }; -// The result of resolving a performance map against the live bank blob. `zones` are the -// zones whose sampleId still resolves to a bank sample, IN MAP ORDER (so overlap-order is -// preserved). `droppedSampleIds` are the ids that no longer resolve (STALE-ID POLICY: a -// zone naming a deleted/moved-out sample is DROPPED cleanly — not an error, not silence -// for the whole map — and its id is reported here so the editor can flag/prune it). +// `zones` are the zones whose sampleId still resolves, IN MAP ORDER (overlap-order +// preserved). `droppedSampleIds`: a zone naming a deleted/moved-out sample is dropped +// cleanly — not an error, not silence for the whole map — and reported here so the editor +// can flag/prune it. struct ResolvedPerformance { std::vector zones; std::vector droppedSampleIds; }; -// Resolve a performance map against the live "banks" ext-state blob. Pure: shared -// bank_book parse, no host, no PCM. Each zone's sampleId is looked up across every bank -// (pool + named); a hit yields a ResolvedZone with the effective root note (rootOverride, -// else the sample's S2 rootNote, else 60) and the sample's loop intrinsic; a miss appends -// the id to droppedSampleIds. Empty/malformed blob or empty map -> empty result. +// Resolve a performance map against the live "banks" ext-state blob. Each zone's sampleId +// is looked up across every bank; a hit yields a ResolvedZone with the effective root note +// and loop intrinsic; a miss appends to droppedSampleIds. Empty/malformed blob or empty map +// -> empty result. // -// NOT the live load path since pS: reloadInstrument resolves via resolvePerformanceFromRefs -// (the instance-owned refs). This bank-side resolver is retained as the TESTED REFERENCE -// the refs path is verified against (testResolveFromRefsMatchesBankResolve) — both share -// foldZone, so the drift test is what keeps the shared fold honest. +// NOT the live load path — reloadInstrument resolves via resolvePerformanceFromRefs (the +// instance-owned refs). Retained as the TESTED REFERENCE the refs path is verified against +// (both share foldZone, so the drift test keeps the shared fold honest). ResolvedPerformance resolvePerformance(const std::string& banksJson, const PerformanceMap& map); -// Resolve a performance map against the INSTANCE-OWNED refs table (pS self-contained -// playback) — the bank-free mirror of resolvePerformance, sharing the same override- -// beats-intrinsic fold, so the two paths cannot drift. A zone whose sampleId has no ref -// is dropped + reported (same stale-id shape as the bank path). Pure. +// The bank-free mirror of resolvePerformance, against the INSTANCE-OWNED refs table — +// shares the same override-beats-intrinsic fold, so the two paths cannot drift. A zone +// whose sampleId has no ref is dropped + reported (same stale-id shape as the bank path). ResolvedPerformance resolvePerformanceFromRefs(const SampleRefs& refs, const PerformanceMap& map); -// Build a zoned Keymap from resolved zones + their decoded mono PCM. `decoded[i]` is the -// downmixed frames + sample rate for `zones[i]` (same length + order as `zones`). One -// SampleData per zone (Tier 1: one sample per key-region; a sample used by two zones is -// decoded twice — acceptable at this tier, the shell may dedup by path later). Zone order -// is preserved so first-match overlap resolution matches the map's authored order. A zone -// whose decoded frames are empty is SKIPPED (an unreadable WAV drops the zone, not the -// map). Empty zones in -> empty Keymap (silence). +// Build a zoned Keymap from resolved zones + their decoded mono PCM. `decoded[i]` matches +// `zones[i]` in length + order. One SampleData per zone (a sample used by two zones is +// decoded twice — acceptable here, the shell may dedup by path later). Zone order preserved +// so first-match overlap resolution matches authored order. A zone whose decoded frames are +// empty is SKIPPED (an unreadable WAV drops the zone, not the map). struct DecodedZonePcm { std::vector monoFrames; // channel 0 (mono, or L of a stereo decode) - int sampleRate = 0; // 0 is explicitly invalid; every consumer must - // receive the WAV's real rate before use. + int sampleRate = 0; // 0 is explicitly invalid std::vector framesR; // channel 1 (R); EMPTY for a mono decode }; Keymap buildZonedKeymap(const std::vector& zones, const std::vector& decoded); -// Apply the S7 cross-mode channel policy (D-E) to freshly-decoded interleaved PCM, yielding -// the 1- or 2-channel DecodedZonePcm the keymap build consumes. `interleaved` is the WAV's -// float frames (stride = `sourceChannels`); `mode` is the instance's channel mode. -// * MONO mode -> downmix to one channel (the existing policy: average all source -// channels). framesR EMPTY. A mono or stereo source both collapse. -// * STEREO mode, mono src -> DUAL-MONO: channel 0 duplicated into channel 1 (centered). -// * STEREO mode, stereo src -> channels 0 and 1 taken as-is (L/R). A source with >2 channels -// takes channels 0 and 1 (documented; the sampler's stereo image is -// the first two channels — no surround fold). -// Empty / zero-channel input -> a DecodedZonePcm with empty frames (the caller drops the zone -// or plays silence). Pure — the shell does the file I/O and hands the interleaved buffer here. +// Apply the cross-mode channel policy to freshly-decoded interleaved PCM, yielding the 1- or +// 2-channel DecodedZonePcm the keymap build consumes. `interleaved` is the WAV's float +// frames (stride = `sourceChannels`); `mode` is the instance's channel mode. +// * MONO mode -> downmix to one channel (average all source channels). +// * STEREO mode, mono src -> dual-mono: channel 0 duplicated into channel 1 (centered). +// * STEREO mode, stereo+ src -> channels 0 and 1 as-is (no surround fold on >2 channels). +// Empty/zero-channel input -> empty frames (caller drops the zone or plays silence). DecodedZonePcm decodeChannels(const std::vector& interleaved, int sourceChannels, ChannelMode mode, int sampleRate); -// The ComponentState envelope + zones-payload binary codec (serializePerformance / -// serializeComponentState / serializeSelection + the deserializers and every version -// constant) lives in component_state_io.h (Q-W2v split, T4-13 ≡ T2-07): the codec grows -// on every envelope bump and is consumed by the EXTENSION's preset-blob path too — the -// split lets both artifacts share the codec while only the VST links the voice engine. +// The ComponentState envelope + zones-payload binary codec lives in component_state_io.h: +// it grows on every envelope bump and is consumed by the extension's preset-blob path too, +// so both artifacts share the codec while only the VST links the voice engine. } // namespace reasampler::instrument::map diff --git a/src/core/instrument/map/trigger_seam.cpp b/src/core/instrument/map/trigger_seam.cpp index 812fb3b..096bb76 100644 --- a/src/core/instrument/map/trigger_seam.cpp +++ b/src/core/instrument/map/trigger_seam.cpp @@ -1,4 +1,4 @@ -// trigger_seam.cpp — PURE Trigger-mode frames↔fraction converter (see trigger_seam.h). +// trigger_seam.cpp — see trigger_seam.h. #include "core/instrument/map/trigger_seam.h" diff --git a/src/core/instrument/map/trigger_seam.h b/src/core/instrument/map/trigger_seam.h index 6539589..2d24975 100644 --- a/src/core/instrument/map/trigger_seam.h +++ b/src/core/instrument/map/trigger_seam.h @@ -1,25 +1,10 @@ -// trigger_seam.h — PURE Trigger-mode frames↔fraction converter for the S-VIEW-3 envelope seam. -// NO VST3, NO REAPER, NO SWELL/LICE types at the boundary. +// trigger_seam — converts Trigger fade lengths between the engine domain (TriggerParams: +// SOURCE FRAMES, anchored to the source-timeline read pointer) and the overlay domain +// (AmpEnvelope: FRACTIONS in [0,1] of the played span, so the drawn shape stays invariant +// across sample-rate changes). Owns the one shared pack/unpack formula so both directions +// stay consistent; reasampler_editor calls these from packEnvelope/unpackEnvelope. // -// The TRIGGER SEAM (documented in envelope_overlay.h) converts between the two representations -// of Trigger fade lengths: -// -// ENGINE domain (TriggerParams / sampler_core): SOURCE FRAMES — int64_t absolute frame counts -// that anchor directly to the voice's source-timeline read pointer. -// -// OVERLAY domain (AmpEnvelope / envelope_overlay): FRACTIONS — doubles in [0,1] of the played -// span, where the played span is: -// playLengthFrames = round(lengthFraction * (frameCount - startFrame)) -// The overlay stores fractions so the drawn shape stays invariant across sample-rate changes; -// the engine stores frames so the voice advances correctly at the live rate. -// -// This module owns the one shared formula so the pack (frames->fractions) and unpack -// (fractions->frames) paths are provably consistent and unit-tested independently of the shell. -// The shell (reasampler_editor.cpp) calls these two functions from packEnvelope / unpackEnvelope. -// -// S-VIEW-F2 safety: the fractions produced here are in [0,1] by construction; a caller that -// clamps the fractions to [0,1] before writing the AmpEnvelope preserves the slider-range -// invariant (a drag can never produce a value a slider couldn't reach). +// playLengthFrames = round(lengthFraction * (frameCount - startFrame)) #pragma once @@ -27,25 +12,18 @@ namespace reasampler::instrument::map { -// The source-frame length of the Trigger played span: -// postStart = max(0, frameCount - startFrame) -// playLength = round(lengthFraction * postStart) -// `frameCount` is the total decoded sample length in source frames. -// `startFrame` is the effective start point (zone.startPoint, or 0 when absent). -// `lengthFraction` is TriggerParams::lengthFraction — (0,1], the fraction of the post-start span. -// Returns 0 when postStart == 0 or lengthFraction <= 0. +// postStart = max(0, frameCount - startFrame); playLength = round(lengthFraction * postStart). +// `startFrame` is the effective start point (0 when absent). Returns 0 when postStart == 0 +// or lengthFraction <= 0. std::int64_t triggerPlayLength(double lengthFraction, std::int64_t frameCount, std::int64_t startFrame); -// Convert a source-frame fade count to a fraction of the play span (PACK direction, draw path). -// Returns 0.0 when playLength == 0 (degenerate sample or zero %-length); the fraction is -// NOT clamped — the caller clamps to [0,1] when filling AmpEnvelope so the overlay clamp logic -// stays in envelope_edit, not here. +// PACK direction (draw path): frames -> fraction of play span. Not clamped here — the +// caller clamps to [0,1] when filling AmpEnvelope (envelope_edit owns that logic). double framesToFadeFraction(std::int64_t fadeFrames, std::int64_t playLength); -// Convert a fade fraction to a source-frame count (UNPACK direction, commit path). -// Rounds to nearest integer frame. Returns 0 when playLength == 0. +// UNPACK direction (commit path): fraction -> nearest source frame. std::int64_t fadeFractionToFrames(double fadeFraction, std::int64_t playLength); } // namespace reasampler::instrument::map diff --git a/src/core/instrument/ui/browser_scroll.cpp b/src/core/instrument/ui/browser_scroll.cpp index c88ef1b..9c68c05 100644 --- a/src/core/instrument/ui/browser_scroll.cpp +++ b/src/core/instrument/ui/browser_scroll.cpp @@ -1,9 +1,8 @@ -// browser_scroll.cpp — see browser_scroll.h. PURE scroll + search geometry over the S10 -// capture_browser. No host types; only the shared Rect + BrowserLayout. +// browser_scroll.cpp — see browser_scroll.h. Pure scroll + search geometry; no host types. #include "core/instrument/ui/browser_scroll.h" -#include "core/instrument/ui/editor_geometry.h" // kPad / kTitleHeight / kNavButtonWidth (Q-W2v hoist) +#include "core/instrument/ui/editor_geometry.h" // kPad / kTitleHeight / kNavButtonWidth #include #include @@ -50,11 +49,10 @@ VisibleRange visibleCardRange(const BrowserLayout& layout, int cardCount, int of return vr; } if (offset < 0) offset = 0; - // First visible ROW: the topmost row whose bottom edge is below the offset. Floor so a row - // partially scrolled off the top still draws (its lower part is visible). + // First row: floored so a row partially scrolled off the top still draws. Last row: + // the row containing pixel (offset + gridH - 1), +1 for the exclusive end, so a row + // straddling the bottom edge still draws. const int firstRow = offset / kBrowserCardHeight; - // Last visible ROW: the row containing the pixel (offset + gridH - 1), inclusive; +1 for - // the exclusive end. A row straddling the bottom edge still draws. const int lastRow = (offset + gridH - 1) / kBrowserCardHeight + 1; int first = firstRow * columns; int last = lastRow * columns; @@ -84,13 +82,11 @@ Rect scrollThumbRect(const BrowserLayout& layout, int cardCount, int offset) { const int trackLeft = trackRight - kScrollbarWidth; const int trackTop = layout.grid.y; - // Thumb height proportional to the visible fraction, floored at a grabbable minimum but - // never taller than the track. + // Thumb height proportional to the visible fraction, floored/capped to the track. int thumbH = static_cast(static_cast(gridH) * gridH / content); thumbH = (std::max)(kMinThumbHeight, thumbH); thumbH = (std::min)(thumbH, gridH); - // Thumb top proportional to the offset over the movable track span. const int trackSpan = gridH - thumbH; // >= 0 int thumbTop = trackTop; if (maxOff > 0 && trackSpan > 0) { @@ -106,7 +102,7 @@ int thumbDragToOffset(const BrowserLayout& layout, int cardCount, int startOffse const int gridH = (std::max)(0, layout.grid.height); if (content <= gridH || gridH <= 0) return clampScrollOffset(layout, cardCount, startOffset); - // Thumb height (same formula as scrollThumbRect) -> movable track span in thumb pixels. + // Same thumb-height formula as scrollThumbRect. int thumbH = static_cast(static_cast(gridH) * gridH / content); thumbH = (std::max)(kMinThumbHeight, thumbH); thumbH = (std::min)(thumbH, gridH); @@ -157,8 +153,6 @@ std::vector filterNameIndices(const std::vector& names, return out; } -// The Browse-modal regions (hoisted from the editor shell, Q-W2v/T2-06 — body verbatim; -// the band metrics come from editor_geometry, the search height from searchBoxRect). BrowseModal computeBrowseModal(int w, int h) { constexpr int kBrowseFooterH = 30; BrowseModal m; diff --git a/src/core/instrument/ui/browser_scroll.h b/src/core/instrument/ui/browser_scroll.h index 566ee69..e61c731 100644 --- a/src/core/instrument/ui/browser_scroll.h +++ b/src/core/instrument/ui/browser_scroll.h @@ -1,23 +1,16 @@ -// browser_scroll.h — PURE scroll + type-to-filter geometry LAYERED over the S10 -// capture_browser. NO VST3, NO REAPER, NO SWELL/LICE types at the boundary. The mirror of -// capture_browser / editor_geometry: the fiddly scroll-window + scrollbar-thumb + search-box -// arithmetic lives here, unit-tested outside the DAW, while the editor shell draws the -// clipped card window + the scrollbar + the search field and routes wheel/drag/keystrokes -// into these functions. +// browser_scroll.h — scroll + type-to-filter geometry layered over capture_browser. Mirror +// of capture_browser/editor_geometry; the shell draws the clipped card window, scrollbar, +// and search field, and routes wheel/drag/keystrokes into these functions. // -// WHY IT EXISTS (S12). capture_browser (S10) lays out EVERY card top-down and the shell -// clips at the browser bottom — a bank longer than the panel runs off with no way to reach -// it (the S12 gap). This module adds the two things S12 layers over that stable geometry: -// * SCROLL — a vertical pixel offset into the card grid, with the max-offset clamp, the -// visible-row window, a scrollbar thumb rect, and the thumb-drag<->offset mapping so a -// wheel tick or a thumb drag reaches every card; and -// * SEARCH — a name-substring filter (case-insensitive) that narrows the drawn cards, -// COMPOSING with capture_browser's bank filter (the shell applies the bank filter first, -// then this search narrows within it) + the search-box rect the shell draws the field in. +// capture_browser lays out every card top-down and the shell clips at the browser bottom — +// a bank longer than the panel has no way to reach the rest. This module adds scroll (a +// vertical pixel offset with max-offset clamp, visible-row window, scrollbar thumb, and +// thumb-drag<->offset mapping) and search (a case-insensitive name-substring filter that +// composes with capture_browser's bank filter — the shell applies the bank filter first, +// then this search narrows within it). // -// It holds NO card data and draws nothing — it knows only the browser layout (from -// capture_browser), COUNTS, and the scroll OFFSET the shell owns as transient UI state. It -// reuses capture_browser's BrowserLayout + the shared Rect (one geometry idiom). +// Holds no card data and draws nothing — knows only the browser layout, counts, and the +// scroll offset the shell owns as transient UI state. #pragma once @@ -28,96 +21,74 @@ namespace reasampler::instrument::ui { -// The width (px) of the vertical scrollbar gutter at the right edge of the grid. The shell -// draws the track + thumb here and hit-tests thumb grabs against scrollThumbRect. Exposed so -// the shell and tests agree. When the content fits (no scroll needed) the scrollbar is -// suppressed (scrollThumbRect returns empty) and the shell may reclaim the gutter. +// Width of the vertical scrollbar gutter at the grid's right edge. When content fits (no +// scroll needed), scrollThumbRect returns empty and the shell may reclaim the gutter. inline constexpr int kScrollbarWidth = 10; -// The height (px) of the type-to-filter search box the shell draws ABOVE the tab strip (a -// thin band spanning the browser width). Exposed so the shell reserves the band and tests -// agree. capture_browser's tab strip + grid sit BELOW this band (the shell offsets the -// BrowserLayout it feeds to capture_browser by kSearchBoxHeight). +// Height of the type-to-filter search box the shell draws above the tab strip. +// capture_browser's tab strip + grid sit below this band. inline constexpr int kSearchBoxHeight = 22; -// The total pixel HEIGHT the card grid needs to draw all `cardCount` cards at `layout`'s -// column count: the number of ROWS (ceil(cardCount / columns)) times the fixed cell height. -// Zero cards -> 0. Pure — the content extent the scroll offset ranges over. +// Total pixel height the card grid needs for `cardCount` cards at `layout`'s column +// count: rows (ceil(cardCount / columns)) times the fixed cell height. int scrollContentHeight(const BrowserLayout& layout, int cardCount); -// The maximum scroll offset (px): content height minus the visible grid height, floored at 0. -// When the content fits within the grid this is 0 (nothing to scroll). Pure — the clamp -// ceiling for every offset the shell tracks. +// Maximum scroll offset: content height minus visible grid height, floored at 0. int scrollMaxOffset(const BrowserLayout& layout, int cardCount); -// Clamp a proposed scroll offset into [0, scrollMaxOffset]. The shell clamps after every wheel -// tick / thumb drag so an over-scroll pins to an edge rather than showing past the last card -// or above the first. Pure. +// Clamps a proposed scroll offset into [0, scrollMaxOffset]. int clampScrollOffset(const BrowserLayout& layout, int cardCount, int proposedOffset); -// The half-open range of card INDICES [first, last) at least partially visible in the grid at -// scroll `offset`. The shell draws only these cards (the S12 clip window) rather than every -// card. `offset` is assumed pre-clamped (the shell clamps on input); a first past the last row -// yields an empty range (first==last==cardCount). Pure. +// Half-open range of card indices [first, last) at least partially visible at scroll +// `offset` (assumed pre-clamped). The shell draws only these cards. struct VisibleRange { - int first = 0; // first card index drawn (inclusive) - int last = 0; // one past the last card index drawn (exclusive) + int first = 0; + int last = 0; }; VisibleRange visibleCardRange(const BrowserLayout& layout, int cardCount, int offset); -// The cell rect of card `index` SHIFTED UP by the scroll offset, ready to draw (the shell -// still adds the browser sub-area origin). Equivalent to capture_browser::cardCellRect with -// the offset subtracted from top/bottom. Pure — the one place the offset applies to a card. +// Cell rect of card `index` shifted up by the scroll offset (the shell still adds the +// browser sub-area origin). Rect scrolledCardCellRect(const BrowserLayout& layout, int index, int offset); -// The vertical scrollbar THUMB rect within the grid's right-edge gutter, sized proportional to -// the visible fraction (grid height / content height) and positioned proportional to the -// scroll offset. Returns an EMPTY rect when the content fits (no scroll needed) — the shell -// suppresses the scrollbar then. A minimum thumb height keeps a tiny thumb grabbable on a very -// long bank. Pure — the geometry the shell draws + hit-tests the thumb grab against. +// Vertical scrollbar thumb rect within the grid's right-edge gutter, sized proportional +// to the visible fraction and positioned proportional to the scroll offset. Empty when +// the content fits. A minimum thumb height keeps a tiny thumb grabbable on a long bank. Rect scrollThumbRect(const BrowserLayout& layout, int cardCount, int offset); -// Map a thumb-drag to a scroll offset. Given the offset the thumb held at grab time -// (`startOffset`) and the vertical pixel delta since grab (`dyPixels`), returns the new -// (clamped) scroll offset: startOffset shifted by the delta scaled from thumb-track pixels to -// content pixels (a 1px thumb move covers content/track px of content). A degenerate track / -// fitting content pins to startOffset. Pure — the inverse of scrollThumbRect's position map. +// Maps a thumb-drag to a new (clamped) scroll offset: `startOffset` shifted by the pixel +// delta scaled from thumb-track pixels to content pixels. A degenerate track or fitting +// content pins to startOffset. int thumbDragToOffset(const BrowserLayout& layout, int cardCount, int startOffset, int dyPixels); -// The search-box rect: a full-width band of height kSearchBoxHeight at the TOP of the browser -// area (above where capture_browser's tab strip draws). `w` is the browser sub-area width; -// the shell adds its origin. A zero/negative width yields an empty rect. Pure. +// Search-box rect: full-width band of height kSearchBoxHeight at the top of the browser +// area. `w` is the browser sub-area width; the shell adds its origin. Rect searchBoxRect(int w); -// True iff `name` contains `query` as a case-insensitive ASCII substring. An EMPTY query -// matches everything (the no-filter identity). Matching is ASCII case-folded (the display -// names are ASCII until the Phase L type kit lands, mirroring the editor's other ASCII-only -// text). Pure — the single match predicate the shell's search narrow is built from. +// True iff `name` contains `query` as a case-insensitive ASCII substring. An empty query +// matches everything. bool nameMatchesQuery(const std::string& name, const std::string& query); -// Narrow a list of display `names` to the INDICES whose name matches `query`, preserving -// order. An EMPTY query returns every index [0, names.size()) (the composition base so "bank -// filter, no search" == today's browser). Kept name-only (indices, not card structs) so this -// module stays free of the sample_map/bank_book chain — the shell owns the SampleChoice list -// and applies the bank filter FIRST, then feeds the surviving display names here (search -// narrows within the bank). Pure. +// Narrows a list of display `names` to the indices whose name matches `query`, preserving +// order. An empty query returns every index. Kept name-only (indices, not card structs) +// so this module stays free of the sample_map/bank_book chain — the shell applies the +// bank filter first, then feeds the surviving display names here. std::vector filterNameIndices(const std::vector& names, const std::string& query); -// --- The Browse-modal (S-VIEW-5) top-level regions (Q-W2v hoist, T2-06) ------- +// --- Browse-modal top-level regions -------------------------------------------- // // A title band with a Back button, the search box, the browser sub-area (tabs + card // grid — layoutBrowser's origin), and a footer with Cancel / Load-confirm. The picker -// covers the full window (F3: full-window overlay). Draw + hit-test both derive from -// this single layout so they never drift. Homed here (not editor_geometry) because the -// search-box height feeds it — browser_scroll already owns the search/scroll geometry. +// covers the full window. Homed here (not editor_geometry) because the search-box +// height feeds it. struct BrowseModal { Rect title; - Rect back; // the "Back" title-band button - Rect search; // the type-to-filter box (absolute) - Rect content; // the browser sub-area (tabs + grid) — layoutBrowser's origin - Rect cancel; // footer Cancel - Rect confirm; // footer Load (confirm) + Rect back; + Rect search; + Rect content; // browser sub-area (tabs + grid) — layoutBrowser's origin + Rect cancel; + Rect confirm; }; BrowseModal computeBrowseModal(int w, int h); diff --git a/src/core/instrument/ui/capture_browser.cpp b/src/core/instrument/ui/capture_browser.cpp index 5ed9517..8573a05 100644 --- a/src/core/instrument/ui/capture_browser.cpp +++ b/src/core/instrument/ui/capture_browser.cpp @@ -8,9 +8,9 @@ namespace reasampler::instrument::ui { namespace { -// The left edge of tab i in a strip of the given x-origin and width divided into `count` -// equal segments (mirror of mode_switch::segmentEdge). Every boundary derives from the same -// formula, so consecutive tabs share an exact edge and the last tab reaches x+width exactly. +// Left edge of tab i in a strip divided into `count` equal segments (mirror of +// mode_switch::segmentEdge). Same formula for every boundary so consecutive tabs share +// an exact edge. int tabEdge(int x, int width, int i, int count) { return x + (i * width) / count; } diff --git a/src/core/instrument/ui/capture_browser.h b/src/core/instrument/ui/capture_browser.h index 73ecbe7..9965b4a 100644 --- a/src/core/instrument/ui/capture_browser.h +++ b/src/core/instrument/ui/capture_browser.h @@ -1,92 +1,67 @@ -// capture_browser.h — PURE layout + hit-test for the S10 capture-first editor's default -// face: a scannable grid of capture CARDS with a bank-FILTER tab strip above it. NO VST3, -// NO REAPER, NO SWELL/LICE types at the boundary. The mirror of editor_geometry / -// embed_strip / mode_switch: the fiddly card-grid + tab arithmetic lives here so it is -// unit-tested outside the DAW, while the editor shell draws each card's peak thumbnail + -// name + root/key badge and routes clicks into these functions. +// capture_browser.h — layout + hit-test for the capture-first editor's default face: a +// scannable grid of capture cards with a bank-filter tab strip above it. Mirror of +// editor_geometry/embed_strip/mode_switch; the shell draws thumbnails/names/badges and +// routes clicks into these functions. // -// The browser replaces the old text item-list (the named anti-pattern). It lays out N -// cards in a fixed-cell grid that wraps across the browser width, and a horizontal tab -// strip of bank filters (one tab per bank_book bank + an "All" tab) above the grid. This -// module knows only COUNTS and RECTS — it draws nothing and holds no sample data; the -// shell owns the SampleChoice list, the peak envelopes, and the filter state, and asks this -// module only "where does card i draw" / "what did the user click". +// This module knows only counts and rects — it draws nothing and holds no sample data; +// the shell owns the SampleChoice list, peak envelopes, and filter state. // -// Scroll is NOT here (S12 layers it over this module). The browser lays out every card -// top-down; the shell clips at the browser's bottom until S12 adds a scroll offset. Keeping -// scroll out keeps this module the stable card/tab geometry S12 builds on. -// -// It reuses the same Rect + contains() as editor_geometry (one shared geometry idiom). +// Scroll is layered on top by browser_scroll — this module lays out every card top-down +// and the shell clips at the bottom until a scroll offset is applied. #pragma once -#include "core/instrument/ui/editor_geometry.h" // Rect, contains — one shared geometry idiom +#include "core/instrument/ui/editor_geometry.h" // Rect, contains namespace reasampler::instrument::ui { -// Fixed browser metrics, exposed so the shell and tests agree. The card is sized to show a -// peak thumbnail with a name + badge line under it — scannable by eye, not a dense list. -inline constexpr int kBrowserTabHeight = 26; // the bank-filter tab strip band height -inline constexpr int kBrowserCardWidth = 132; // one card cell width (incl. gutter) -inline constexpr int kBrowserCardHeight = 84; // one card cell height (incl. gutter) -inline constexpr int kBrowserCardGutter = 8; // inset between the cell edge and the card -inline constexpr int kBrowserThumbHeight = 44; // the peak-thumbnail band inside a card +// Fixed browser metrics, exposed so the shell and tests agree. +inline constexpr int kBrowserTabHeight = 26; +inline constexpr int kBrowserCardWidth = 132; +inline constexpr int kBrowserCardHeight = 84; +inline constexpr int kBrowserCardGutter = 8; +inline constexpr int kBrowserThumbHeight = 44; -// The browser's regions, derived from the (w x h) area the shell allots it. Both clamp to -// the area so a degenerate (tiny/zero) size never yields an inverted rect. +// Clamped so a degenerate (tiny/zero) size never yields an inverted rect. struct BrowserLayout { - Rect tabStrip; // top: the bank-filter tabs - Rect grid; // below the tabs: where the capture cards tile - int columns = 1; // cards per row in `grid` (>= 1); derived from grid.width + Rect tabStrip; + Rect grid; + int columns = 1; // cards per row in `grid` (>= 1) }; -// Divide a (w x h) browser area into its regions and compute the column count. Pure: same -// inputs -> same layout. The tab strip takes a fixed height at the top (clamped so it never -// exceeds the area); the grid takes the rest. columns = max(1, grid.width/cardWidth) so a -// browser narrower than one card still lays out a single column. A zero/negative size -// yields empty rects + columns==1. +// Divide a (w x h) browser area into its regions and compute the column count. columns = +// max(1, grid.width/cardWidth) so a browser narrower than one card still lays out a +// single column. BrowserLayout layoutBrowser(int w, int h); -// The cell rect of capture card `index` (0-based) in the grid, laid out left-to-right then -// top-to-bottom across `columns`. This is the full CELL (card + gutter); cardContentRect -// insets it to the drawable card. Rows past the visible grid are still computed (the shell -// clips at paint time). A negative index yields an empty rect. Pure. +// Cell rect of capture card `index` (0-based), left-to-right then top-to-bottom across +// `columns`. This is the full cell (card + gutter); cardContentRect insets it. Rect cardCellRect(const BrowserLayout& layout, int index); -// The drawable card rect inside a cell: the cell inset by kBrowserCardGutter on all sides. -// The shell fills this (background + border) and draws the thumbnail/name/badge inside it. Pure. +// Drawable card rect inside a cell: the cell inset by kBrowserCardGutter on all sides. Rect cardContentRect(const BrowserLayout& layout, int index); -// The peak-thumbnail sub-rect at the top of a card's content: full card width, the top -// kBrowserThumbHeight (clamped to the card height). The shell draws the envelope here; the -// name + badge go in the remaining strip below. Pure. +// Peak-thumbnail sub-rect at the top of a card's content: full card width, the top +// kBrowserThumbHeight (clamped to the card height). Rect cardThumbnailRect(const BrowserLayout& layout, int index); -// The name/badge sub-rect below the thumbnail: the card content minus the thumbnail band. -// The shell draws the display name + root/key badge here. Pure. +// Name/badge sub-rect below the thumbnail. Rect cardLabelRect(const BrowserLayout& layout, int index); -// The card a click at (x, y) lands on, given `cardCount` cards, or -1 for a click outside -// every card (in a gutter, past the last card, or on the tab strip). Only the card CONTENT -// rect counts as a hit — a click in the inter-card gutter is a miss. Pure. +// Card a click at (x, y) lands on, given `cardCount` cards, or -1 for a miss. Only the +// card content rect counts as a hit — a click in the inter-card gutter is a miss. int cardHitTest(const BrowserLayout& layout, int cardCount, int x, int y); -// --- Bank-filter tabs -------------------------------------------------------- +// --- Bank-filter tabs --------------------------------------------------------- // -// The tab strip divides tabStrip into `tabCount` equal segments (mirror of mode_switch): -// one tab per bank_book bank plus a leading "All" tab the shell prepends, so tabCount == -// bankCount + 1 in practice. This module only divides the strip + hit-tests; the shell -// supplies the labels and tracks which tab is active. A tab click narrows the card list to -// that bank (the shell filters its SampleChoice list before laying out cards). +// Divides tabStrip into `tabCount` equal segments: one tab per bank plus a leading "All" +// tab the shell prepends. This module only divides the strip + hit-tests; the shell +// supplies labels and tracks the active tab. -// The rect of tab `index` (0-based) when the strip is divided into `tabCount` equal -// segments. The last tab absorbs any width remainder so the tabs tile the whole strip with -// no gap (mirror of mode_switch's segment split). A negative index or tabCount<=0 yields an -// empty rect. Pure. +// Rect of tab `index` when the strip is divided into `tabCount` equal segments. The last +// tab absorbs any width remainder so the tabs tile the whole strip with no gap. Rect filterTabRect(const BrowserLayout& layout, int tabCount, int index); -// The tab a click at (x, y) lands on, given `tabCount` tabs, or -1 for a click outside the -// tab strip. Pure. int filterTabHitTest(const BrowserLayout& layout, int tabCount, int x, int y); } // namespace reasampler::instrument::ui diff --git a/src/core/instrument/ui/curve_popup.h b/src/core/instrument/ui/curve_popup.h index 3213ec2..3eea28d 100644 --- a/src/core/instrument/ui/curve_popup.h +++ b/src/core/instrument/ui/curve_popup.h @@ -1,17 +1,12 @@ -// curve_popup.h — PURE sheet geometry + dismissal test for the r11 velocity-curve popup -// editor (Wave B, FB1). NO VST3, NO REAPER, NO SWELL/LICE types at the boundary. The mirror -// of overflow_menu: the size-clamp / centering / title-row arithmetic lives here, unit-tested -// at the clamps outside the DAW, while the editor shell draws the wash + sheet through the -// L1 kit and routes clicks (close / curve box / outside-sheet dismiss) via these rects. +// curve_popup.h — sheet geometry + dismissal test for the velocity-curve popup editor. +// Mirror of overflow_menu; the shell draws through the L1 kit and routes clicks via +// these rects. // -// THE POPUP (CONTEXT.md §S-VIEW r11). Summoned by the mini curve-preview button, a CENTERED -// SHEET over the Sample face (a 0.50-alpha bg/base wash behind it — lighter than Browse's -// 0.82; a focused sub-editor, not a view change): width clamp(60% of window, 360..520), -// height clamp(55% of window, 260..380). Inside: a ~22px title row ("VELOCITY -> AMP" -// micro-caps left, an 18x18 Close button right) over the full-size curve box filling the -// remainder. The curve box rect here is the BORDER rect — the shell derives the mapping box -// through its ONE curveBoxFromRect formula (the landed inset grammar), so the popup editor -// and the Zone-panel inline editor share coordinates by construction. +// A centered sheet over the Sample face (a lighter wash than Browse's, since this is a +// focused sub-editor, not a view change): width/height each clamp to a fraction of the +// window within min/max bounds. A title row sits over the curve box. The curve box rect +// here is the border rect — the shell derives the mapping box via its curveBoxFromRect +// formula, so the popup editor and the Zone-panel inline editor share coordinates. #pragma once @@ -19,7 +14,7 @@ namespace reasampler::instrument::ui { -// Fixed popup metrics (spec r11), exposed so the shell and tests agree. +// Fixed popup metrics, exposed so the shell and tests agree. inline constexpr int kCurvePopupMinW = 360; inline constexpr int kCurvePopupMaxW = 520; inline constexpr int kCurvePopupMinH = 260; @@ -29,20 +24,19 @@ inline constexpr int kCurvePopupCloseSize = 18; inline constexpr int kCurvePopupPad = 8; // sheet inner padding (title inset + box margins) struct CurvePopupLayout { - Rect sheet; // the bg/panel sheet, centered in the window - Rect title; // the caption text rect (left part of the title row) - Rect close; // the 18x18 Close (x) button, right-anchored in the title row - Rect curveBox; // the full-size curve editor BORDER rect (shell insets via curveBoxFromRect) + Rect sheet; + Rect title; + Rect close; // Close (x) button, right-anchored in the title row + Rect curveBox; // full-size curve editor border rect (shell insets via curveBoxFromRect) }; -// The popup geometry for a (w x h) window: sheet width clamp(60% w, 360..520) and height -// clamp(55% h, 260..380) — each additionally capped at the window dimension so a degenerate -// window never yields an overhanging sheet — centered; title row + close button at the top; -// the curve box filling the remainder inside kCurvePopupPad margins. Pure. +// Popup geometry for a (w x h) window: sheet width clamp(60% w, min..max) and height +// clamp(55% h, min..max), each additionally capped at the window dimension so a +// degenerate window never yields an overhanging sheet — centered. CurvePopupLayout computeCurvePopup(int w, int h); -// True when (x, y) lands OUTSIDE the sheet (on the wash) — the click-outside dismissal test. -// The shell additionally gates on "no drag in flight" (spec). Pure. +// True when (x, y) lands outside the sheet (on the wash) — the click-outside dismissal +// test. The shell additionally gates on "no drag in flight". bool popupOutsideSheet(const CurvePopupLayout& layout, int x, int y); } // namespace reasampler::instrument::ui diff --git a/src/core/instrument/ui/editor_geometry.cpp b/src/core/instrument/ui/editor_geometry.cpp index 4cbc809..f1342c6 100644 --- a/src/core/instrument/ui/editor_geometry.cpp +++ b/src/core/instrument/ui/editor_geometry.cpp @@ -8,8 +8,6 @@ namespace reasampler::instrument::ui { namespace { -// Spike editor layout constants. These are the editor's fixed metrics; the real -// editor (S4/S5) will parameterize as its content demands. constexpr int kTitleBarHeight = 28; constexpr int kButtonMargin = 10; constexpr int kButtonWidth = 120; @@ -17,26 +15,18 @@ constexpr int kButtonHeight = 24; } // namespace -// contains() now lives with the shared ui::Rect (core/ui/rect.h) — same half-open -// semantics, re-exported through the header's using-declaration. - EditorLayout layoutEditor(int w, int h) { - // Clamp the surface to non-negative extents so a degenerate view can't produce - // inverted rects. + // Clamp to non-negative extents so a degenerate view can't produce inverted rects. const int cw = std::max(0, w); const int ch = std::max(0, h); EditorLayout out; - // Title bar spans the top, clamped so it never exceeds the client height. const int titleH = std::min(kTitleBarHeight, ch); out.titleBar = Rect::ltrb(0, 0, cw, titleH); - - // Canvas is everything below the title bar. out.canvas = Rect::ltrb(0, titleH, cw, ch); - // Button sits at the top-left of the canvas, inset by a margin, and is clamped to - // fit inside the canvas so it never overhangs on a small view. + // Button inset from the canvas top-left, clamped so it never overhangs a small view. const int bx = out.canvas.x + kButtonMargin; const int by = out.canvas.y + kButtonMargin; const int bRight = std::min(bx + kButtonWidth, out.canvas.right()); @@ -59,15 +49,11 @@ Rect sampleRowRect(const EditorLayout& layout, int index) { int sampleRowHitTest(const EditorLayout& layout, int rowCount, int x, int y) { if (rowCount <= 0) return -1; - // Must be within the canvas horizontally and at/below its top. if (x < layout.canvas.x || x >= layout.canvas.right()) return -1; if (y < layout.canvas.y) return -1; - // Clip at the canvas bottom: clicks in the canvas's dead-zone below the last - // visible row agree with sampleRowRect, which does not clamp rows to canvas.bottom(). if (y >= layout.canvas.bottom()) return -1; const int index = (y - layout.canvas.y) / kSampleRowHeight; if (index < 0 || index >= rowCount) return -1; - // Guard the bottom edge: a click below the last row's bottom is outside. const Rect r = sampleRowRect(layout, index); if (y >= r.bottom()) return -1; return index; @@ -80,9 +66,6 @@ KeymapEditorLayout layoutKeymapEditor(int w, int h) { out.base = layoutEditor(w, h); const Rect& canvas = out.base.canvas; - // Split the canvas vertically: the left column is the bank-sample list, the right - // column (1/kZonePanelFraction of the width) is the zone panel. Guard tiny widths so - // the split point never crosses the canvas edges. const int canvasW = std::max(0, canvas.width); const int splitW = canvasW / kZonePanelFraction; // width of the zone panel const int splitX = std::max(canvas.x, canvas.right() - splitW); @@ -90,13 +73,11 @@ KeymapEditorLayout layoutKeymapEditor(int w, int h) { out.sampleList = Rect::ltrb(canvas.x, canvas.y, splitX, canvas.bottom()); out.zonePanel = Rect::ltrb(splitX, canvas.y, canvas.right(), canvas.bottom()); - // "Add Zone" button spans the top of the zone panel, clamped to its height. const int addH = std::min(kAddZoneHeight, std::max(0, out.zonePanel.height)); out.addZoneButton = Rect::ltrb(out.zonePanel.x, out.zonePanel.y, out.zonePanel.right(), out.zonePanel.y + addH); - // Zone rows stack below the button. out.zoneRowArea = Rect::ltrb(out.zonePanel.x, out.addZoneButton.bottom(), out.zonePanel.right(), out.zonePanel.bottom()); return out; @@ -138,10 +119,8 @@ ZoneHit zoneHitTest(const KeymapEditorLayout& layout, int zoneCount, int x, int const Rect row = zoneRowRect(layout, index); if (y >= row.bottom()) return ZoneHit{}; - // Seven mini-buttons pinned to the right edge, right-to-left: - // delete, root+, root-, high+, high-, low+, low- - // Each is kZoneCtrlWidth wide. A click left of the leftmost is the label ("select"). - // The fields laid out LEFT-TO-RIGHT in slot order 0..6. + // Seven mini-buttons pinned to the right edge, each kZoneCtrlWidth wide, in slot + // order 0..6; a click left of the leftmost is the label ("select"). const ZoneField fields[7] = { ZoneField::kLowDown, ZoneField::kLowUp, ZoneField::kHighDown, ZoneField::kHighUp, ZoneField::kRootDown, ZoneField::kRootUp, @@ -159,35 +138,25 @@ bool addZoneHitTest(const KeymapEditorLayout& layout, int x, int y) { return contains(layout.addZoneButton, x, y); } -// --- r11 Sample / Zone face layout (Q-W2v hoist, T2-06) ---------------------- -// Bodies moved verbatim from the reasampler_editor shell (behavior-identical); the -// only signature change is clusterRects' `knobSize` parameter (formerly knob_deck's -// kDeckKnobSize read directly — passed in so this module stays knob_deck-free). - namespace { -// Fixed band metrics (formerly the editor shell's anon-ns constants). -constexpr int kHeroMinHeight = 150; // the elastic hero's floor (r11) +constexpr int kHeroMinHeight = 150; // elastic hero's floor constexpr int kClusterHeight = 52; // root strip + preview + vel knob + curve btn + channel toggle -constexpr int kStripBandHeight = 40; // the keyboard-strip band height (root strip + zone strip) +constexpr int kStripBandHeight = 40; // keyboard-strip band height (root strip + zone strip) -// The r11 cluster's fixed right-anchored run (left -> right: Preview button, the radial -// preview-velocity knob cell, the mini curve-preview button, Mono|Stereo). +// Cluster's fixed right-anchored run: Preview button, vel knob cell, curve button, Mono|Stereo. constexpr int kPreviewBtnW = 64; -constexpr int kVelCellW = 48; // the Vel knob cell (deck cell grammar) -constexpr int kCurveBtnSize = 28; // the square curve-preview button +constexpr int kVelCellW = 48; +constexpr int kCurveBtnSize = 28; -// The S7 mono/stereo toggle segments. constexpr int kChanSegW = 52; constexpr int kChanSegH = 18; } // namespace -// r11 band order: title (fixed) -> hero (ELASTIC: absorbs all height left after the fixed -// bands, floor kHeroMinHeight) -> cluster (fixed) -> deck (fixed height `deckH`, bottom- -// anchored). When the window is too short for the floor (below the checkSizeConstraint -// minimum — a defensive case), the hero keeps its floor and the lower bands clip past the -// window bottom gracefully. +// Band order: title (fixed) -> hero (elastic, absorbs remaining height, floor +// kHeroMinHeight) -> cluster (fixed) -> deck (fixed height `deckH`, bottom-anchored). A +// window too short for the floor keeps the hero at its floor and clips lower bands. SampleBands computeSampleBands(int w, int h, int deckH) { SampleBands b; const int titleH = (std::min)(kTitleHeight, h); @@ -214,7 +183,6 @@ SampleBands computeSampleBands(int w, int h, int deckH) { return b; } -// Draw + hit-test both derive from this ONE formula. ClusterRects clusterRects(const Rect& cluster, const Rect& chanMono, int knobSize) { ClusterRects r; const int stripTop = cluster.y + (cluster.height - kStripBandHeight) / 2; @@ -261,16 +229,14 @@ Rect zoneDeleteRect(const Rect& addR) { return Rect::ltrb(addR.right() + 8, addR.y, addR.right() + 8 + 64, addR.bottom()); } -// Zone content sits below the "+ Add Zone" affordance (top+4, height 20) with a 12px -// gap, padded kPad horizontally. All call sites use this formula. +// Sits below the "+ Add Zone" affordance (top+4, height 20) with a 12px gap. Rect zonesStripArea(const Rect& content) { const int stripTop = content.y + 4 + 20 + 12; // addR.bottom() + 12 return Rect::ltrb(content.x + kPad, stripTop, content.right() - kPad, stripTop + kStripBandHeight); } -// Anchored off zonesStripArea.bottom() so the legend top tracks the strip bottom -// without re-inlining the strip arithmetic here. +// Anchored off zonesStripArea.bottom() so the legend top tracks the strip bottom. Rect noteEntryFieldsArea(const Rect& content) { const int stripBottom = zonesStripArea(content).bottom(); const int top = stripBottom + 8; // legendTop (== zonesStripArea.bottom() + 8) @@ -292,9 +258,8 @@ Rect zonesControlPanel(const Rect& content) { content.bottom() - 4); } -// FB2 (R11-F2 parity): the deck lays out from the panel top (top-anchored), with a -// column at the panel's right reserved for the mini curve-preview button so no deck row -// starts inside it. +// Top-anchored; reserves a column at the panel's right for the curve-preview button so +// no deck row starts inside it. Rect zonesDeckArea(const Rect& content) { const Rect panel = zonesControlPanel(content); return Rect::ltrb(panel.x, panel.y, panel.right() - kCurveBtnSize - kPad, panel.bottom()); diff --git a/src/core/instrument/ui/editor_geometry.h b/src/core/instrument/ui/editor_geometry.h index b1e8239..4ec5f3e 100644 --- a/src/core/instrument/ui/editor_geometry.h +++ b/src/core/instrument/ui/editor_geometry.h @@ -1,14 +1,6 @@ -// editor_geometry.h — PURE view geometry + hit-test for the VST3 IPlugView LICE -// editor (Phase S1). NO VST3, NO REAPER, NO SWELL/LICE types at the boundary. -// -// The IPlugView shell (reasampler_editor.cpp) owns the window/bitmap/SWELL plumbing -// and is DAW-verified; this module holds the fiddly rectangle math and hit-testing so -// it can be unit-tested outside the DAW — the mirror of how bank_grid / mode_switch / -// tab_strip split their layout math out of the panel shell. -// -// The spike's editor is deliberately trivial (a title band + one clickable button), -// enough to PROVE the host->draw/hit-test event routing works. As the real editor -// (S4/S5) grows, its layout math accretes here, not in the shell. +// editor_geometry.h — view geometry + hit-test for the VST3 IPlugView LICE editor. The +// IPlugView shell owns window/bitmap/SWELL plumbing; the rectangle math and hit-testing +// live here so they can be unit-tested outside the DAW. #pragma once @@ -16,101 +8,80 @@ namespace reasampler::instrument::ui { -// The shared pixel rectangle + containment test (Q-W1, T2-05 ≡ T4-21): the former -// LTRB Rect defined here is folded into the ONE concrete ui::Rect (XYWH storage, -// right()/bottom() accessors, Rect::ltrb() for edge-wise construction, same -// half-open convention). Aliased here so every instrument-ui call site keeps its -// established `Rect` / `contains` spelling. using Rect = ::reasampler::ui::Rect; using ::reasampler::ui::contains; -// The regions the spike editor draws, derived from the current view size. All are -// clamped to the client area so a degenerate (too-small) view never yields a region -// that spills outside the surface. +// Title band + one button + remaining canvas, clamped so a degenerate (too-small) view +// never yields a region spilling outside the surface. struct EditorLayout { - Rect titleBar; // top band: the plugin name + a live-state readout - Rect button; // a single clickable button (proves hit-test routing) - Rect canvas; // the remaining surface below the title bar + Rect titleBar; + Rect button; + Rect canvas; }; -// Divide a (w x h) client area into the spike editor's regions. Pure: the same -// inputs always yield the same layout. Guards tiny sizes — every returned rect stays -// within [0,w] x [0,h], and the button never overhangs the canvas. +// Divide a (w x h) client area into the editor's top-level regions. Pure. EditorLayout layoutEditor(int w, int h); -// The editor's hit-test targets. kNone means the point landed on inert surface. enum class HitTarget { kNone, kButton, }; -// Classify a click at (x, y) against a layout. The button wins only when the point is -// inside the button rect; everything else (including the title bar and empty canvas) -// is kNone in the spike. +// Classify a click at (x, y) against a layout. HitTarget hitTest(const EditorLayout& layout, int x, int y); -// --- Sample-selection list (S4 Tier-0 UI) ----------------------------------- +// --- Sample-selection list --------------------------------------------------- // -// The Tier-0 editor lists the bank's samples as a vertical stack of fixed-height rows -// below the title bar; clicking a row selects that sample. This is the pure geometry: -// the row rectangles and the point->row hit-test, unit-tested outside the DAW while the -// shell draws the names and routes the click into the processor's reloadInstrument. +// A vertical stack of fixed-height rows below the title bar; clicking a row selects that +// sample. Pure geometry only — the shell draws names and routes the click. -// The fixed row height (px) for one sample entry. Exposed so the shell and tests agree. inline constexpr int kSampleRowHeight = 22; -// The rectangle for row `index` (0-based) of the sample list, laid out top-down inside -// the layout's canvas. Rows beyond what the canvas can show are still computed (the -// shell clips at paint time); a negative index yields an empty rect. Pure. +// Rect for row `index` (0-based), laid out top-down inside the layout's canvas. Rows +// beyond what the canvas can show are still computed (the shell clips at paint time); a +// negative index yields an empty rect. Rect sampleRowRect(const EditorLayout& layout, int index); -// The row index a click at (x, y) lands on, given `rowCount` rows, or -1 for a click -// outside the list (above the first row, past the last, or on the title bar). Pure. +// Row index a click at (x, y) lands on given `rowCount` rows, or -1 for a click outside +// the list. int sampleRowHitTest(const EditorLayout& layout, int rowCount, int x, int y); -// --- Keymap editor (S5 Tier-1 UI) ------------------------------------------- +// --- Keymap editor ------------------------------------------------------------ // -// The Tier-1 editor splits the canvas into a LEFT bank-sample list (the same rows as -// Tier 0, reused for the "sample to add / fallback pick") and a RIGHT zone panel listing -// the performance map's zones. An "Add Zone" button sits at the top of the zone panel; -// each zone row carries small nudge/delete controls so the user can set the range and -// root note without a text field (LICE has no native numeric entry). All rectangle math -// is here so the shell only draws + routes — the mirror of the sample-list split above. +// Splits the canvas into a LEFT bank-sample list (the sample-selection rows above, reused +// as the "sample to add / fallback pick") and a RIGHT zone panel listing the performance +// map's zones. An "Add Zone" button sits at the top of the zone panel; each zone row +// carries nudge/delete mini-buttons (LICE has no native numeric entry field). -// Fixed metrics for the zone panel, exposed so the shell and tests agree. inline constexpr int kZoneRowHeight = 24; inline constexpr int kZonePanelFraction = 2; // zone panel gets the RIGHT 1/2 of the canvas inline constexpr int kZoneCtrlWidth = 20; // width of one nudge/delete mini-button -inline constexpr int kAddZoneHeight = 22; // the "Add Zone" button band height +inline constexpr int kAddZoneHeight = 22; // "Add Zone" button band height -// The keymap editor's regions, derived from the (w x h) client area. All clamp to the -// canvas so a degenerate view yields in-bounds rects. +// Clamps every rect to the canvas so a degenerate view still yields in-bounds rects. struct KeymapEditorLayout { - EditorLayout base; // title bar + canvas (the sample list uses base.canvas.x half) - Rect sampleList; // LEFT column: the bank-sample rows (sampleRowRect is relative here) - Rect zonePanel; // RIGHT column: the "Add Zone" button + the zone rows + EditorLayout base; + Rect sampleList; // LEFT column + Rect zonePanel; // RIGHT column Rect addZoneButton; // top of the zone panel - Rect zoneRowArea; // below addZoneButton: where zone rows stack + Rect zoneRowArea; // below addZoneButton }; KeymapEditorLayout layoutKeymapEditor(int w, int h); -// The rectangle for bank-sample row `index` inside the LEFT sample list column of a -// keymap layout. Same fixed height as the Tier-0 list; laid out top-down inside -// sampleList. Negative index -> empty. Pure. +// Rect for bank-sample row `index` inside the LEFT column. Negative index -> empty. Rect keymapSampleRowRect(const KeymapEditorLayout& layout, int index); -// The bank-sample row a click lands on inside the left list, or -1 outside it. Pure. +// Bank-sample row a click lands on inside the left list, or -1 outside it. int keymapSampleRowHitTest(const KeymapEditorLayout& layout, int rowCount, int x, int y); -// The rectangle for zone row `index` inside the zone panel's zoneRowArea. Negative -// index -> empty. Pure. +// Rect for zone row `index` inside zoneRowArea. Negative index -> empty. Rect zoneRowRect(const KeymapEditorLayout& layout, int index); -// A zone row's interactive fields. The row is a horizontal strip: a label on the left, -// then seven fixed-width mini-buttons on the right (left-to-right: low-, low+, high-, high+, -// root-, root+, delete). kZoneNone means the click missed a control -// (e.g. on the label) — the shell may still treat that as "select this zone". +// A zone row's interactive fields: a label on the left, then seven fixed-width +// mini-buttons on the right (low-, low+, high-, high+, root-, root+, delete). kZoneNone +// means the click missed a control (e.g. the label) — the shell may still treat that as +// "select this zone". enum class ZoneField { kZoneNone, kLowDown, @@ -122,101 +93,90 @@ enum class ZoneField { kDelete, }; -// The result of hit-testing a click against the zone rows: which zone row (or -1) and -// which field within it. A click on the "Add Zone" button is reported separately by -// addZoneHitTest — this covers only the zone rows. +// Which zone row (or -1) and which field within it a click landed on. A click on +// "Add Zone" is reported separately by addZoneHitTest. struct ZoneHit { int zoneIndex = -1; ZoneField field = ZoneField::kZoneNone; }; -// Classify a click at (x, y) against `zoneCount` zone rows. Returns {-1, kZoneNone} for a -// click outside every zone row. Within a row, the seven mini-buttons occupy fixed-width -// slots on the right edge (left-to-right: low-, low+, high-, high+, root-, root+, delete); -// a click left of those slots is {index, kZoneNone} (the label area — "select"). Pure. +// Classify a click at (x, y) against `zoneCount` zone rows. {-1, kZoneNone} for a miss. +// Within a row, the seven mini-buttons occupy fixed-width slots on the right edge; a +// click left of those slots is {index, kZoneNone} (the label area — "select"). ZoneHit zoneHitTest(const KeymapEditorLayout& layout, int zoneCount, int x, int y); -// True if (x, y) lands on the "Add Zone" button. Pure. bool addZoneHitTest(const KeymapEditorLayout& layout, int x, int y); -// --- r11 Sample / Zone face layout (Q-W2v hoist, T2-06) ---------------------- +// --- Sample / Zone face layout ------------------------------------------------ // -// The capture-first editor's band/cluster/zone-surface layout math, hoisted out of the -// reasampler_editor shell where it had accreted untestable (the §2 scope gap). Draw and -// hit-test both derive every rect from these ONE formulas so they can never drift; the -// shell only draws + routes. The Browse-modal layout lives in browser_scroll (its search -// box height feeds it — dependency-clean placement beside its scroll/search siblings). +// The capture-first editor's band/cluster/zone-surface layout math. Draw and hit-test +// both derive every rect from these formulas so they can never drift; the shell only +// draws + routes. The Browse-modal layout lives in browser_scroll (its search box +// height feeds it). -// Shared band metrics (the shell's remaining direct uses: horizontal padding + the -// title-band height; everything else is internal to the layout functions below). inline constexpr int kPad = 8; inline constexpr int kTitleHeight = 26; inline constexpr int kNavButtonWidth = 62; // Browse / Zone / Back title-band buttons -// The r11 Sample-face bands (top->bottom): a TITLE band (name + Browse/Zone nav), the -// FULL-WIDTH ELASTIC HERO (absorbs all height left after the fixed bands, floor -// kHeroMinHeight), the ROOT + PREVIEW CLUSTER, and the bottom-anchored KNOB DECK -// (height `deckH` from the pure knob_deck wrap). When the window is too short for the -// hero floor (below the checkSizeConstraint minimum — defensive), the hero keeps its -// floor and the lower bands clip past the window bottom gracefully. +// Sample-face bands (top->bottom): TITLE (name + Browse/Zone nav), a full-width elastic +// HERO (absorbs all height left after the fixed bands, floored), the root+preview +// CLUSTER, and the bottom-anchored knob DECK (height `deckH` from knob_deck's wrap). A +// window shorter than the hero floor clips the lower bands past the window bottom. struct SampleBands { - Rect title; // top: name + Browse/Zone nav buttons - Rect navBrowse; // the "Browse" title-band button - Rect navZone; // the "Zone" title-band button - Rect hero; // the FULL-WIDTH ELASTIC hero waveform + S-VIEW-3 envelope overlay + Rect title; + Rect navBrowse; + Rect navZone; + Rect hero; // waveform + envelope overlay Rect cluster; // root strip + preview + vel knob + curve button + channel toggle - Rect deck; // the bottom-anchored knob deck (height from the pure knob_deck wrap) + Rect deck; }; SampleBands computeSampleBands(int w, int h, int deckH); -// The r11 cluster sub-rects: the root strip keeps the left side at REMAINDER width; the -// right side is the fixed-width right-anchored run (Preview 64 · Vel knob cell 48 · curve -// preview button 28 · Mono|Stereo). `knobSize` is the deck knob square (knob_deck's -// kDeckKnobSize — passed in so this module does not depend on knob_deck). +// Cluster sub-rects: the root strip keeps the left side at remainder width; the right +// side is the fixed-width right-anchored run (Preview · vel knob cell · curve button · +// Mono|Stereo). `knobSize` is the deck knob square, passed in so this module does not +// depend on knob_deck. struct ClusterRects { - Rect rootStrip; // remainder-width fenced root strip - Rect preview; // the preview-trigger button - Rect velCell; // the radial preview-velocity knob cell (knob + label band) - Rect velKnob; // the knob square at the cell's top - Rect velLabel; // the label band beneath it - Rect curveBtn; // the mini curve-preview button (opens the popup) + Rect rootStrip; + Rect preview; + Rect velCell; // preview-velocity knob cell (knob + label band) + Rect velKnob; + Rect velLabel; + Rect curveBtn; // opens the curve-preview popup }; ClusterRects clusterRects(const Rect& cluster, const Rect& chanMono, int knobSize); -// The S7 mono/stereo toggle: a two-segment control right-anchored in `area`, vertically -// centered. Returns {mono-segment, stereo-segment}, side by side. +// Mono/stereo toggle: a two-segment control right-anchored in `area`, vertically centered. struct ChannelToggleRects { Rect mono; Rect stereo; }; ChannelToggleRects channelToggleRects(const Rect& area); -// The Zone-view (S-VIEW-8) content area: the whole window below the title band. +// Zone-view content area: the whole window below the title band. Rect zoneContentArea(int w, int h); -// The Zone/Browse "Back" title-band button (right-anchored — the same slot the Sample -// face's Zone nav button occupies). +// Zone/Browse "Back" button — the same slot the Sample face's Zone nav button occupies. Rect zoneBackRect(int w, int h); -// The "+ Add Zone" affordance at the top of the Zone content, and the "Delete" button -// beside it (Delete only draws/hits when a zone is selected). +// "+ Add Zone" affordance and the "Delete" button beside it (Delete only draws/hits +// when a zone is selected). Rect zoneAddRect(const Rect& content); Rect zoneDeleteRect(const Rect& addR); -// The Zone-view keyboard strip rect: below the "+ Add Zone" affordance with a 12px gap, -// padded kPad horizontally. +// Zone-view keyboard strip rect: below "+ Add Zone" with a 12px gap, padded kPad +// horizontally. Rect zonesStripArea(const Rect& content); -// The S12 numeric-entry field ROW area inside the Zones legend (a band to the right of -// the sample label), and the rect of field `f` (0=low, 1=high, 2=root) within it — -// three equal segments left-to-right. An out-of-range index yields an empty rect. +// Numeric-entry field row area inside the Zones legend, and the rect of field `f` +// (0=low, 1=high, 2=root) within it — three equal segments left-to-right. Out-of-range +// index yields an empty rect. Rect noteEntryFieldsArea(const Rect& content); Rect noteEntryFieldRect(const Rect& fields, int f); -// The per-zone parameter panel below the strip + the one-line legend, running to the -// content bottom; the FB2 knob-deck area within it (a column at the right reserved for -// the mini curve-preview button); and that button's rect (the cluster's 28px square, -// right-anchored at the panel top). +// Per-zone parameter panel below the strip + legend, running to the content bottom; the +// knob-deck area within it (a right column reserved for the curve-preview button); and +// that button's rect (right-anchored at the panel top). Rect zonesControlPanel(const Rect& content); Rect zonesDeckArea(const Rect& content); Rect zonesCurveButton(const Rect& content); diff --git a/src/core/instrument/ui/embed_strip.cpp b/src/core/instrument/ui/embed_strip.cpp index 7b8d827..dec0dcd 100644 --- a/src/core/instrument/ui/embed_strip.cpp +++ b/src/core/instrument/ui/embed_strip.cpp @@ -8,17 +8,15 @@ namespace reasampler::instrument::ui { namespace { -// Clamp a MIDI note to [0, kEmbedKeyCount-1]. int clampNote(int n) { if (n < 0) return 0; if (n > kEmbedKeyCount - 1) return kEmbedKeyCount - 1; return n; } -// Map a key boundary in [0, kEmbedKeyCount] to an x pixel inside a band of the given -// left/width. keyEdge is a boundary (0..128), so keyEdge==128 maps to the band's right. -// Integer math, floored — a zone's left uses floor(low) and its right uses floor(high+1), -// which tiles adjacent zones without a seam. +// Maps a key boundary (0..128) to an x pixel; keyEdge==128 maps to the band's right. A +// zone's left uses floor(low) and its right uses floor(high+1), tiling adjacent zones +// without a seam. int keyEdgeToX(int bandLeft, int bandWidth, int keyEdge) { if (keyEdge <= 0) return bandLeft; if (keyEdge >= kEmbedKeyCount) return bandLeft + bandWidth; @@ -33,9 +31,8 @@ EmbedLayout layoutEmbed(int w, int h) { EmbedLayout out; - // The level band takes a fixed height at the bottom, but never so much that the keymap - // above it falls below its minimum (or that the band exceeds the area). On a very short - // area the band yields to the keymap entirely. + // Fixed height at the bottom, but never so much that the keymap falls below its + // minimum; on a very short area the band yields to the keymap entirely. int bandH = std::min(kEmbedLevelBandHeight, ch); if (ch - bandH < kEmbedKeymapMinHeight) { bandH = std::max(0, ch - kEmbedKeymapMinHeight); diff --git a/src/core/instrument/ui/embed_strip.h b/src/core/instrument/ui/embed_strip.h index 1c28193..2caee05 100644 --- a/src/core/instrument/ui/embed_strip.h +++ b/src/core/instrument/ui/embed_strip.h @@ -1,76 +1,57 @@ -// embed_strip.h — PURE layout + hit-test for the S6 embedded TCP/MCP strip. NO VST3, -// NO REAPER, NO SWELL/LICE types at the boundary. The mirror of editor_geometry / -// mode_switch: the fiddly rectangle math for the compact inline keymap/level strip lives -// here so it is unit-tested outside the DAW, while the embed shell (reasampler_embed.cpp) -// marshals REAPER's embed messages (paint bitmap + mouse coords) into these functions. +// embed_strip.h — layout + hit-test for the embedded TCP/MCP strip. Mirror of +// editor_geometry/mode_switch; the embed shell marshals REAPER's embed messages (paint +// bitmap + mouse coords) into these functions. // -// The strip is a single compact band REAPER draws inline in the track/mixer control panel -// (context TCP or MCP) via the Cockos embedded-UI surface. It shows: -// * the zone layout — each performance zone as a horizontal segment across the keyboard -// span (MIDI 0..127 mapped to the strip width), so the keymap reads at a glance; and -// * a thin level band at the bottom — a 0..1 activity indicator the shell fills. -// Interaction is zone SELECTION at most (S6 constraint: no new editing semantics) — a -// click maps to the zone whose key range covers that point, or -1. -// -// It reuses the same Rect + contains() as editor_geometry (the strip and the editor share -// one geometry idiom), so this header depends on editor_geometry.h rather than redefining -// a second rectangle type. +// A single compact band REAPER draws inline in the track/mixer control panel via the +// Cockos embedded-UI surface: each performance zone as a horizontal segment across the +// keyboard span (MIDI 0..127 mapped to the strip width), plus a thin activity level band +// at the bottom. Interaction is zone selection only — no editing. #pragma once -#include "core/instrument/ui/editor_geometry.h" // Rect, contains — one shared geometry idiom +#include "core/instrument/ui/editor_geometry.h" // Rect, contains namespace reasampler::instrument::ui { -// The full MIDI key span the strip maps across its width. 128 keys (0..127); the strip's -// horizontal axis is this range, so a zone [lowNote, highNote] becomes a sub-rectangle. inline constexpr int kEmbedKeyCount = 128; // Fixed metrics for the strip, exposed so the shell and tests agree. -inline constexpr int kEmbedLevelBandHeight = 4; // the bottom activity band (px) -inline constexpr int kEmbedKeymapMinHeight = 6; // keymap area collapses no smaller +inline constexpr int kEmbedLevelBandHeight = 4; +inline constexpr int kEmbedKeymapMinHeight = 6; -// One zone rendered on the strip: its inclusive MIDI key range. This is the minimal -// projection of a PerformanceZone the strip needs (it does not carry sample ids or PCM — -// the shell resolves labels; the strip only lays out ranges). lowNote/highNote are -// expected in [0,127] with low <= high, but the layout clamps defensively so a malformed -// zone never yields an out-of-strip rect. +// One zone rendered on the strip: its inclusive MIDI key range — the minimal projection +// of a PerformanceZone the strip needs (no sample ids or PCM). Expected in [0,127] with +// low <= high; layout clamps defensively regardless. struct EmbedZone { int lowNote = 0; int highNote = 127; }; -// The strip's regions, derived from the (w x h) embed area REAPER reports. Both clamp to -// the area so a degenerate (tiny) size never yields a region spilling outside the surface. +// Clamped to the area so a degenerate (tiny) size never yields a region spilling outside +// the surface. struct EmbedLayout { - Rect keymap; // top: the zone-segment band (the compact keymap) - Rect levelBand; // bottom: the thin level/activity indicator + Rect keymap; // top: zone-segment band + Rect levelBand; // bottom: level/activity indicator }; -// Divide a (w x h) embed area into the strip's regions. Pure: same inputs -> same layout. -// The level band takes a fixed height at the bottom (clamped so it never exceeds the area -// or starves the keymap below kEmbedKeymapMinHeight); the keymap takes the rest. A zero or -// negative size yields empty rects (no inversion). +// Divide a (w x h) embed area into the strip's regions. The level band takes a fixed +// height at the bottom (clamped so it never starves the keymap below +// kEmbedKeymapMinHeight); the keymap takes the rest. EmbedLayout layoutEmbed(int w, int h); -// The horizontal sub-rectangle of the keymap band for a zone spanning [lowNote, highNote] -// (inclusive). The 128-key span maps linearly across keymap.width; the returned rect -// spans the half-open pixel range [x(lowNote), x(highNote+1)) so adjacent zones (e.g. -// 0..59 and 60..127) tile without a gap or overlap. Notes are clamped to [0,127] and low -// is clamped to <= high, so a malformed zone yields an in-band (possibly zero-width) rect, -// never an inverted one. Pure. +// Horizontal sub-rect of the keymap band for a zone spanning [lowNote, highNote] +// (inclusive). Spans the half-open pixel range so adjacent zones tile without a gap or +// overlap. Notes clamp to [0,127] and low clamps to <= high. Rect zoneSegmentRect(const EmbedLayout& layout, int lowNote, int highNote); -// The zone a click at (x, y) lands on, given the zones in draw order, or -1 for a click -// outside the keymap band or on a key not covered by any zone. When zones overlap on a -// key, the FIRST covering zone in order wins — mirroring the sampler core's first-match -// Keymap::resolve and the editor's zone order, so selection agrees with playback. Pure. +// Zone a click at (x, y) lands on, given zones in draw order, or -1 for a miss. When +// zones overlap on a key, the first covering zone in order wins — mirroring the sampler +// core's first-match Keymap::resolve, so selection agrees with playback. int zoneAtPoint(const EmbedLayout& layout, const EmbedZone* zones, int zoneCount, int x, int y); -// The filled portion of the level band for a 0..1 level. Clamps level to [0,1]; the -// returned rect is the left sub-rectangle of levelBand whose width is level * band width -// (rounded down). level <= 0 -> empty rect; level >= 1 -> the whole band. Pure. +// Filled portion of the level band for a 0..1 level (clamped); left sub-rect of levelBand +// whose width is level * band width. Rect levelFillRect(const EmbedLayout& layout, double level); } // namespace reasampler::instrument::ui diff --git a/src/core/instrument/ui/envelope_edit.cpp b/src/core/instrument/ui/envelope_edit.cpp index 9b050f1..cb0c742 100644 --- a/src/core/instrument/ui/envelope_edit.cpp +++ b/src/core/instrument/ui/envelope_edit.cpp @@ -9,34 +9,28 @@ namespace reasampler::instrument::ui { namespace { - -// Seconds represented by one horizontal pixel under the overlay's linear time base. Zero when the -// area is degenerate (the caller then produces no motion). Matches envelope_overlay::timeToX. +// Matches envelope_overlay::timeToX. Zero when the area is degenerate (no motion). double secondsPerPixel(const Rect& area, double totalSeconds) { const int w = std::max(0, area.width); if (w <= 0 || totalSeconds <= 0.0) return 0.0; return totalSeconds / static_cast(w); } -// Seconds per pixel for a GATE time-node drag (FA2): the reciprocal of the overlay's -// param-domain gatePxPerSecond(area) scale — sample-length-free, matching -// envelope_overlay::gatePolyline exactly so the dragged handle tracks the cursor 1:1 (each -// node's x is affine in its own segment duration with slope gatePxPerSecond). Zero when the -// area is degenerate. +// Reciprocal of the overlay's gatePxPerSecond, matching gatePolyline's scale exactly so a +// dragged handle tracks the cursor 1:1. double gateSecondsPerPixel(const Rect& area) { const double pps = gatePxPerSecond(area); return pps > 0.0 ? 1.0 / pps : 0.0; } -// Level (0..1) represented by one vertical pixel. levelToY spans (height-1) rows for [0,1], so one -// pixel is 1/(height-1). Zero when degenerate. Matches envelope_overlay::levelToY. +// Matches envelope_overlay::levelToY (spans height-1 rows for [0,1]). double levelPerPixel(const Rect& area) { const int h = std::max(0, area.height); if (h <= 1) return 0.0; return 1.0 / static_cast(h - 1); } -// True for the nodes the user can grab-and-drag (Origin + ReleaseStart are draw-only anchors). +// Origin + ReleaseStart are draw-only anchors, not grabbable. bool isDraggable(EnvNode n) { switch (n) { case EnvNode::Origin: @@ -47,10 +41,8 @@ bool isDraggable(EnvNode n) { } } -// True when the node belongs to the envelope's active mode. Guards the degenerate cross-mode -// write: the degenerate baseline polyline carries a ReleaseEnd vertex regardless of mode, so a -// zero-height Trigger-mode grab of it must not write releaseSeconds (and vice versa for Gate -// nodes vs Trigger fields). Applied by BOTH the hit-test and the drag resolver so they agree. +// Guards the degenerate baseline's cross-mode ReleaseEnd vertex from writing releaseSeconds in +// Trigger mode (and vice versa). Applied by both the hit-test and the drag resolver. bool nodeInMode(EnvNode n, EnvMode m) { switch (n) { case EnvNode::AttackEnd: @@ -64,7 +56,7 @@ bool nodeInMode(EnvNode n, EnvMode m) { return m == EnvMode::Trigger; case EnvNode::Origin: case EnvNode::ReleaseStart: - return false; // never draggable in any mode (isDraggable filters these anyway) + return false; } return false; } @@ -73,18 +65,15 @@ bool nodeInMode(EnvNode n, EnvMode m) { NodeHit nodeAtPoint(const AmpEnvelope& env, const Rect& area, double totalSeconds, int x, int y) { const std::vector poly = buildEnvelopePolyline(env, area, totalSeconds); - // NEAREST draggable, mode-matching node within the pick radius wins (Chebyshev distance — - // the square grab box); ties break to the earlier draw-order node (FA2). Gate nodes never - // coincide (the forward map enforces kGateNodeSepPx separation), so the tie-break only - // matters for Trigger's zero-fade-out coincidence: FadeOutStart overlays LengthEnd, WINS the - // tie, and can be dragged inward from the right edge. The mode filter keeps the degenerate - // baseline's ReleaseEnd vertex from registering as a grabbable node in Trigger mode. + // Nearest draggable, mode-matching node within the pick radius wins (Chebyshev distance); + // ties go to the earlier draw-order node. Only matters for Trigger's zero-fade-out + // coincidence (FadeOutStart overlaps LengthEnd and wins). NodeHit best; int bestDist = kNodeGrabRadius + 1; for (const EnvVertex& v : poly) { if (!isDraggable(v.node) || !nodeInMode(v.node, env.mode)) continue; const int dist = std::max(std::abs(x - v.x), std::abs(y - v.y)); - if (dist < bestDist) { // strictly closer only: earlier draw order keeps ties + if (dist < bestDist) { // strict-less-than keeps ties at the earlier draw order bestDist = dist; best = NodeHit{true, v.node}; } @@ -101,16 +90,12 @@ AmpEnvelope resolveNodeDrag(const AmpEnvelope& grabEnv, EnvNode node, const Rect const double secPerPx = secondsPerPixel(area, totalSeconds); if (secPerPx <= 0.0) return out; // degenerate area / duration — no motion const double dSec = static_cast(dxPixels) * secPerPx; - // Gate time nodes use the schematic's PARAM-DOMAIN px->seconds scale (FA2) — the reciprocal - // of the overlay's gatePxPerSecond, sample-length-free — so the dragged handle tracks the - // cursor 1:1. gateTimedWidth >= 1 whenever the area is non-empty, so gateDSec is - // well-defined past the degenerate guard above. const double gateDSec = static_cast(dxPixels) * gateSecondsPerPixel(area); switch (node) { - // --- Gate: each cumulative-time node edits its OWN segment duration. Non-negative - // durations ARE the monotonic-in-time guarantee (a node can never cross a neighbour - // because every segment stays >= 0), so the [0, max] clamp is the whole constraint. + // Gate: each cumulative-time node edits its own segment duration. Non-negative durations + // ARE the monotonic-in-time guarantee (a segment can never go negative, so a node can + // never cross a neighbour) — the [0, max] clamp is the whole constraint. case EnvNode::AttackEnd: out.attackSeconds = std::clamp(grabEnv.attackSeconds + gateDSec, 0.0, bounds.maxAttackSeconds); @@ -119,8 +104,7 @@ AmpEnvelope resolveNodeDrag(const AmpEnvelope& grabEnv, EnvNode node, const Rect out.holdSeconds = std::clamp(grabEnv.holdSeconds + gateDSec, 0.0, bounds.maxHoldSeconds); break; case EnvNode::DecayEnd: { - // Sustain node: X sets decay time, Y sets sustain level (drag DOWN = higher y = lower - // level, so subtract the level delta). + // X sets decay time, Y sets sustain level (drag down = higher y = lower level). out.decaySeconds = std::clamp(grabEnv.decaySeconds + gateDSec, 0.0, bounds.maxDecaySeconds); const double lvlPerPx = levelPerPixel(area); const double dLevel = -static_cast(dyPixels) * lvlPerPx; @@ -132,17 +116,9 @@ AmpEnvelope resolveNodeDrag(const AmpEnvelope& grabEnv, EnvNode node, const Rect std::clamp(grabEnv.releaseSeconds + gateDSec, 0.0, bounds.maxReleaseSeconds); break; - // --- Trigger: fades + length are FRACTIONS. X pixels convert to a fraction of the PLAYED - // span (fades) or the whole sample (length). Monotonic: fadeIn + fadeOut <= 1 so the - // two fade nodes never cross (each clamps against the other), and length in [0, max]. - // - // TRIGGER SEAM — CONVERSION REQUIRED ON BOTH PATHS (Wave 2 shell author, read this): - // fadeInFraction/fadeOutFraction in AmpEnvelope are fractions of the played span. - // TriggerParams (sampler_core.h) stores the corresponding values as SOURCE FRAMES - // (fadeInFrames/fadeOutFrames, int64_t). The shell owes a converter on BOTH directions: - // pack (draw): fadeInFrames/fadeOutFrames -> fraction (needs frameCount + rate) - // unpack (commit): fraction -> fadeInFrames/fadeOutFrames (same inputs) - // See the TRIGGER SEAM note on AmpEnvelope in envelope_overlay.h for the formula. + // Trigger: fades + length are fractions. X pixels convert to a fraction of the played + // span (fades) or the whole sample (length). fadeIn + fadeOut <= 1 keeps the two fade + // nodes from crossing (each clamps against the other). case EnvNode::FadeInEnd: { if (dxPixels == 0) break; // zero-motion grab: no param change, no division const double playSeconds = std::max(0.0, grabEnv.lengthFraction) * totalSeconds; diff --git a/src/core/instrument/ui/envelope_edit.h b/src/core/instrument/ui/envelope_edit.h index 7d7699c..8437fcc 100644 --- a/src/core/instrument/ui/envelope_edit.h +++ b/src/core/instrument/ui/envelope_edit.h @@ -1,41 +1,18 @@ -// envelope_edit.h — PURE node hit-test + pixel-delta→clamped-param inverse map for the S-VIEW-3 -// draggable envelope nodes. NO VST3, NO REAPER, NO SWELL/LICE types at the boundary. The mirror -// of card_drag / waveform_view: the drag arithmetic + clamp/monotonic constraints live here, -// unit-tested at the boundaries outside the DAW, while the editor shell (reasampler_editor.cpp) -// draws the handles, captures the grab on WM_LBUTTONDOWN, feeds each move's pixel delta back -// through here, and commits the resulting params to the zone through the same off-audio-thread -// path a slider edit uses. +// envelope_edit.h — node hit-test + pixel-delta -> clamped-param inverse map for the draggable +// envelope nodes. Mirror of card_drag/waveform_view: drag arithmetic lives here, unit-tested +// outside the DAW; the shell draws handles, captures the grab, and feeds pixel deltas back in. // -// TWO SURFACES, ONE MODEL. envelope_overlay owns the params→polyline FORWARD map (draw); this -// module owns the pixel→params INVERSE map (edit) + node hit-test. Both read/write the SAME -// AmpEnvelope fields (the shell re-reads the zone every paint — no listener chain), so a node -// drag and a slider edit are two views on one source of truth and can never diverge. +// envelope_overlay owns the params->polyline forward (draw) map; this module owns the inverse +// (edit) map + hit-test. Both read/write the same AmpEnvelope fields (shell re-reads the zone +// every paint), so a node drag and a slider edit are two views on one source of truth. // -// THE INVARIANT (S-VIEW-F2). A drag can NEVER produce a param a slider couldn't: -// * MONOTONIC IN TIME — a node clamps between its time predecessor and successor, so attack-end -// can't pass hold-end, decay can't pass release, etc. Each segment stays >= 0. -// * RANGE-CLAMPED — times clamp to the SAME per-param [min,max] the slider enforces; levels -// clamp to [0,1]. Because the concrete second/fraction maxima live SHELL-SIDE (param_slider -// is deliberately engine-free — the shell owns the 0..1↔domain mapping), the clamp bounds are -// CALLER-SUPPLIED here (EnvClampBounds): the shell passes the same maxima it feeds the slider, -// so the two surfaces share one clamp by construction. +// A drag can never produce a param a slider couldn't: nodes are monotonic in time (clamped +// between time predecessor/successor) and range-clamped to the same per-param [min,max] the +// slider uses (EnvClampBounds, caller-supplied since those maxima live shell-side). // -// WHICH AXES. Time-only nodes (AttackEnd, HoldEnd, ReleaseEnd; FadeInEnd, FadeOutStart, -// LengthEnd) drag on X only. The sustain node (DecayEnd) drags on BOTH axes — its X sets the -// decay time, its Y sets the sustain level (the standard ADSR-editor grammar). Origin and the -// drawing-only ReleaseStart vertex are NOT draggable. -// -// GATE DRAG SCALE (FA2). Gate time nodes convert px->seconds via the reciprocal of the -// schematic's PARAM-DOMAIN scale (envelope_overlay's gatePxPerSecond — sample-length-free), so -// a dragged handle tracks the cursor exactly 1:1 for stages within the schematic domain (each -// node's x is affine in its own segment duration). Trigger nodes keep the full-canvas -// PCM-aligned scale. Both match the forward map in envelope_overlay. A node is only editable in -// its OWN mode: Gate nodes ignore drags while the envelope is in Trigger mode and vice versa -// (guards the degenerate baseline's cross-mode ReleaseEnd vertex from writing releaseSeconds). -// -// Reuses editor_geometry's Rect + the EnvNode / AmpEnvelope / EnvMode types from -// envelope_overlay (one shared node vocabulary across draw + edit), and the shared timeToX / -// levelToY maps so the handle the overlay drew and the grab region here agree pixel-for-pixel. +// Time-only nodes drag on X; DecayEnd (the sustain node) drags on both axes (X = decay time, +// Y = sustain level). Origin and the drawing-only ReleaseStart are not draggable. A node is only +// editable in its own mode (Gate nodes ignore drags in Trigger mode and vice versa). #pragma once @@ -47,60 +24,46 @@ namespace reasampler::instrument::ui { -// The pick radius (px) around a node's drawn point: a grab within this many pixels (in BOTH x and -// y) of a node handle grabs it. Mirrors waveform_view's kMarkerGrabWidth — wide enough to grab a -// small handle comfortably, narrow enough that adjacent nodes stay distinguishable. +// Pick radius (px) around a node's drawn point, in both x and y. Mirrors waveform_view's +// kMarkerGrabWidth. inline constexpr int kNodeGrabRadius = 6; -// The per-param clamp bounds the shell supplies (the SAME maxima its sliders map 0..1 onto). All -// are upper bounds in the param's own domain; the lower bound is 0 (each stage >= 0), and the -// monotonic-in-time constraint tightens these further at edit time. Defaults are conservative -// placeholders; the shell OVERRIDES them with its live slider domain so the clamp matches exactly. +// Per-param clamp bounds the shell supplies — the same maxima its sliders map [0,1] onto. +// Lower bound is always 0; the monotonic-in-time constraint tightens further at edit time. +// Defaults are placeholders; the shell overrides with its live slider domain. struct EnvClampBounds { - double maxAttackSeconds = 4.0; // upper bound of the attack slider + double maxAttackSeconds = 4.0; double maxHoldSeconds = 4.0; double maxDecaySeconds = 4.0; double maxReleaseSeconds = 4.0; - // Trigger fades + length are fractions; their natural upper bound is 1.0. Exposed so a shell - // that caps a fade below the full span (e.g. 0.5) shares that cap with its slider. double maxFadeInFraction = 1.0; double maxFadeOutFraction = 1.0; double maxLengthFraction = 1.0; - // sustainLevel is always [0,1] — no shell knob needed, kept implicit. + // sustainLevel is always [0,1] — no shell knob needed. }; -// Which node a grab at (x, y) lands on, given the CURRENT envelope + overlay rect + sample -// duration (the same inputs buildEnvelopePolyline drew from, so the grab tests the drawn handles). -// Returns EnvNode::Origin's NON-membership as a miss via the bool return: `hit` is false for a -// point off every DRAGGABLE node. Origin and ReleaseStart are never returned (not draggable), -// and a node from the OTHER mode is never returned (the degenerate baseline's ReleaseEnd vertex -// is not grabbable in Trigger mode). The NEAREST node within the radius wins (Chebyshev -// distance); an exact tie goes to the earlier draw-order node (FA2 — deterministic). Gate nodes -// never coincide (the forward map enforces kGateNodeSepPx separation, so every Gate handle is -// individually grabbable in every state); the tie-break matters only for Trigger's zero-fade-out -// coincidence, where FadeOutStart overlays LengthEnd, wins the tie, and can be dragged inward -// from the right edge. Pure. +// Which node a grab at (x, y) lands on, given the current envelope/rect/duration (the same +// inputs buildEnvelopePolyline drew from). `hit` is false for a point off every draggable node; +// Origin/ReleaseStart and nodes from the other mode never hit. Nearest node within the radius +// wins (Chebyshev distance); an exact tie goes to the earlier draw-order node — this only matters +// for Trigger's zero-fade-out coincidence (FadeOutStart overlaps LengthEnd and wins, so the fade +// can be dragged open from zero). Gate nodes never coincide (forward map enforces +// kGateNodeSepPx), so every Gate handle is independently grabbable. struct NodeHit { bool hit = false; EnvNode node = EnvNode::Origin; // meaningful only when hit == true }; NodeHit nodeAtPoint(const AmpEnvelope& env, const Rect& area, double totalSeconds, int x, int y); -// Resolve a drag of `node` to a new AmpEnvelope. Given the envelope AS OF GRAB TIME (`grabEnv` — -// the shell snapshots it on WM_LBUTTONDOWN so the delta is absolute, not accumulated), the overlay -// rect + sample duration (the pixel↔param maps), the caller's clamp bounds, and the pixel delta -// since grab (`dxPixels`, `dyPixels`), returns the envelope the node should now describe: -// * X delta -> the node's TIME param, shifted proportionally (same linear map as timeToX), -// clamped to [0, per-param max] AND to its monotonic-in-time neighbours (>= predecessor time, -// <= successor time). For a cumulative-time node the shift lands on that node's OWN segment -// duration (e.g. dragging HoldEnd changes holdSeconds, not attack). -// * Y delta -> the LEVEL param, but ONLY for the sustain node (DecayEnd); clamped to [0,1]. -// dyPixels is IGNORED for every time-only node. -// * Non-draggable node (Origin / ReleaseStart), a node from the OTHER mode (a Gate node while -// grabEnv.mode is Trigger, or vice versa), a zero-width/zero-height area, or -// totalSeconds <= 0 -> `grabEnv` returned unchanged (no motion). -// Only the dragged node's param(s) change; every other field carries through from `grabEnv`. Pure -// — rounding is to the param's continuous value (no snapping, matching the sliders' resolution). +// Resolves a drag of `node` to a new AmpEnvelope. `grabEnv` is the envelope as of grab time (the +// shell snapshots it on button-down so the delta is absolute, not accumulated); `dxPixels`/ +// `dyPixels` is the pixel delta since grab. +// * X delta -> the node's time param, shifted via the same linear map as timeToX, clamped to +// [0, per-param max] and to its monotonic-in-time neighbours. +// * Y delta -> the level param, only for DecayEnd; clamped to [0,1]. Ignored for time-only nodes. +// * A non-draggable node, an other-mode node, a zero-size area, or totalSeconds <= 0 returns +// `grabEnv` unchanged. +// Only the dragged node's param(s) change. Pure. AmpEnvelope resolveNodeDrag(const AmpEnvelope& grabEnv, EnvNode node, const Rect& area, double totalSeconds, const EnvClampBounds& bounds, int dxPixels, int dyPixels); diff --git a/src/core/instrument/ui/envelope_overlay.cpp b/src/core/instrument/ui/envelope_overlay.cpp index a4bf59d..b5e0ffa 100644 --- a/src/core/instrument/ui/envelope_overlay.cpp +++ b/src/core/instrument/ui/envelope_overlay.cpp @@ -8,15 +8,14 @@ namespace reasampler::instrument::ui { -using util::clamp01; // the ONE unit-interval clamp (Q-W1, T4-24) +using util::clamp01; int timeToX(const Rect& area, double totalSeconds, double t) { const int w = std::max(0, area.width); if (w <= 0 || totalSeconds <= 0.0) return area.x; if (t < 0.0) t = 0.0; - // Linear map, clamped on BOTH sides (FA2 bounds invariant): t past totalSeconds pins to the - // last in-bounds column area.right()-1. Clamp in DOUBLE space BEFORE the integer cast — a huge - // t would overflow a 32-bit long (Windows) and wrap to the WRONG edge — then round. + // Clamp in double space before the int cast — a huge t would overflow a 32-bit long + // (Windows) and wrap to the wrong edge. double px = (t / totalSeconds) * static_cast(w); if (px > static_cast(w - 1)) px = static_cast(w - 1); return area.x + static_cast(px + 0.5); @@ -33,9 +32,7 @@ int gateTimedWidth(const Rect& area) { double gatePxPerSecond(const Rect& area) { const int timedW = gateTimedWidth(area); if (timedW <= 0) return 0.0; - // Usable width = timed region minus the four per-segment separation bases and the last - // in-bounds column, floored at 1 px so the scale never degenerates; the domain is the four - // stages end-to-end at their schematic maxima (param-domain scale — sample-length-free). + // Minus the four per-segment separation bases and the last in-bounds column, floored at 1. const double usable = std::max(1.0, static_cast(timedW - 1 - 4 * kGateNodeSepPx)); return usable / (4.0 * kGateStageMaxSeconds); @@ -46,8 +43,8 @@ int levelToY(const Rect& area, double level) { if (h <= 0) return area.y; if (level < 0.0) level = 0.0; if (level > 1.0) level = 1.0; - // Level 1 -> top row, level 0 -> bottom row (bottom-1 under the half-open convention). The - // range spans (h-1) pixels so both endpoints land ON a drawable row. + // Level 1 -> top row, level 0 -> bottom row; spans (h-1) px so both endpoints land on a + // drawable row. const int span = h - 1; const long dy = static_cast((1.0 - level) * static_cast(span) + 0.5); return area.y + static_cast(dy); @@ -64,10 +61,8 @@ EnvVertex vtx(EnvNode node, const Rect& area, double totalSeconds, double t, dou return v; } -// One Gate vertex from a pixel offset inside the area (the Gate schematic works in px space — -// timed px + the fixed sustain-plateau reserve — not through the plain timeToX map). Clamps x in -// DOUBLE space to the last in-bounds column BEFORE the integer cast (FA2 bounds invariant; a -// huge px would overflow a 32-bit long on Windows and wrap to the WRONG edge). +// Gate works in px space (timed px + the fixed sustain-plateau reserve) rather than the plain +// timeToX map; clamps in double space before the int cast for the same overflow reason as above. EnvVertex gateVtx(EnvNode node, const Rect& area, double px, double level) { const int w = std::max(1, area.width); if (px < 0.0) px = 0.0; @@ -81,18 +76,16 @@ EnvVertex gateVtx(EnvNode node, const Rect& area, double px, double level) { } std::vector gatePolyline(const AmpEnvelope& env, const Rect& area) { - // Non-negative segment durations (a stored negative would be an upstream bug; clamp defensively). + // Clamp defensively — a stored negative duration would be an upstream bug. const double a = std::max(0.0, env.attackSeconds); const double h = std::max(0.0, env.holdSeconds); const double d = std::max(0.0, env.decaySeconds); const double r = std::max(0.0, env.releaseSeconds); const double sus = clamp01(env.sustainLevel); - // BOUNDED SCHEMATIC (FA2): A/H/D and R map onto the TIMED region (canvas minus the reserved - // sustain-plateau width) at the PARAM-DOMAIN scale — sample-length-free — and every segment - // gets a kGateNodeSepPx base so consecutive nodes never coincide (every node individually - // grabbable at any params, incl. the tier-0 zero-hold/zero-decay defaults). The sustain - // plateau is the fixed reserve between DecayEnd and ReleaseStart. + // A/H/D/R map onto the timed region at the param-domain scale, each segment getting a + // kGateNodeSepPx base so nodes never coincide even at the tier-0 zero-hold/zero-decay + // defaults. The sustain plateau is the fixed reserve between DecayEnd and ReleaseStart. const int W = std::max(1, area.width); const double sustainPx = static_cast(W - gateTimedWidth(area)); const double sep = static_cast(kGateNodeSepPx); @@ -104,10 +97,9 @@ std::vector gatePolyline(const AmpEnvelope& env, const Rect& area) { double xPlateau = xDecay + sustainPx; // ReleaseStart (schematic note-off) double xRelease = xPlateau + sep + r * pps; // ReleaseEnd - // Right-edge overrun (a stored stage beyond the schematic domain): compress from the RIGHT - // preserving the minimum gaps, so trailing nodes stay individually separated instead of - // piling on the last column. The re-floor pass only bites when the canvas is too narrow to - // hold the minimum gaps at all — then gateVtx's [0, W-1] clamp wins (in-bounds > separation). + // Overrun beyond the schematic domain compresses from the right, preserving minimum gaps so + // trailing nodes stay separated instead of piling on the last column. This re-floor only + // bites when the canvas is too narrow to hold the gaps at all — gateVtx's clamp wins then. const double xMax = static_cast(W - 1); if (xRelease > xMax) { xRelease = xMax; @@ -135,12 +127,11 @@ std::vector gatePolyline(const AmpEnvelope& env, const Rect& area) { std::vector triggerPolyline(const AmpEnvelope& env, const Rect& area, double totalSeconds) { - // The played span is lengthFraction of the whole sample; fades are fractions OF that span. + // Played span is lengthFraction of the whole sample; fades are fractions of that span. const double len = clamp01(env.lengthFraction); double fadeIn = clamp01(env.fadeInFraction); double fadeOut = clamp01(env.fadeOutFraction); - // Fades cannot overlap: clamp so fadeIn + fadeOut <= 1 (of the played span), mirroring the - // engine's TriggerParams clamp. Trim the LATER fade (fade-out) first, matching the engine. + // Fades cannot overlap; trim fade-out first, matching the engine's TriggerParams clamp. if (fadeIn + fadeOut > 1.0) fadeOut = std::max(0.0, 1.0 - fadeIn); const double playSeconds = len * totalSeconds; @@ -161,12 +152,10 @@ std::vector triggerPolyline(const AmpEnvelope& env, const Rect& area, std::vector buildEnvelopePolyline(const AmpEnvelope& env, const Rect& area, double totalSeconds) { if (area.width <= 0 || area.height <= 0 || totalSeconds <= 0.0) { - // Degenerate surface: a two-point flat baseline at level 0 so the shell always has a line. + // Degenerate surface: flat two-point baseline so the shell always has a line. return {vtx(EnvNode::Origin, area, 1.0, 0.0, 0.0), vtx(EnvNode::ReleaseEnd, area, 1.0, 1.0, 0.0)}; } - // Gate is a param-domain schematic — totalSeconds only gates the degenerate branch above - // (no loaded duration -> baseline); Trigger is PCM-aligned and consumes it. return env.mode == EnvMode::Gate ? gatePolyline(env, area) : triggerPolyline(env, area, totalSeconds); } diff --git a/src/core/instrument/ui/envelope_overlay.h b/src/core/instrument/ui/envelope_overlay.h index e25e647..d65f9e2 100644 --- a/src/core/instrument/ui/envelope_overlay.h +++ b/src/core/instrument/ui/envelope_overlay.h @@ -1,59 +1,7 @@ -// envelope_overlay.h — PURE amp-envelope → polyline geometry for the S-VIEW-3 Sample-view -// envelope overlay. NO VST3, NO REAPER, NO SWELL/LICE types at the boundary. The mirror of -// waveform_view / param_slider: the params→pixel polyline math lives here, unit-tested outside -// the DAW, while the editor shell (reasampler_editor.cpp) traces the polyline in an accent hue -// and draws the node handles (via envelope_edit's hit-test). -// -// WHAT IT DRAWS. The amp envelope over the Sample view's hero waveform (Simpler / Phase-Plant -// grammar): -// * Gate -> the AHDSR shape: attack ramp 0->1, hold plateau at 1, decay 1->sustain, -// sustain plateau, release sustain->0. Since there is no held note-off to draw -// against, Gate is a BOUNDED SCHEMATIC (FA2): a fixed fraction of the canvas -// width (kGateSustainDisplayFraction) is RESERVED for the sustain plateau, and -// the remaining "timed" width carries A/H/D AND the release at the PARAM-DOMAIN -// scale — the timed width represents 4 x kGateStageMaxSeconds (the four stage -// sliders end-to-end at their maxima), NOT the sample's duration, so the layout -// is identical for a 0.3s and a 10s capture. Each segment additionally gets a -// kGateNodeSepPx pixel base, so consecutive nodes NEVER coincide: every Gate -// node is individually grabbable at ANY param values, including the tier-0 -// defaults (hold 0 / decay 0). A -> (H) -> D -> S-plateau -> R all render INSIDE -// the canvas and the release is a visible, draggable segment. -// * Trigger -> the fade/%-length shape: fade-in 0->1, unity plateau, fade-out 1->0 anchored -// to playEnd (= lengthFraction of the post-start span). Trigger keeps the -// waveform's exact time base so the shape lines up with the PCM under it. -// The horizontal axis is TIME (Gate: schematic, see above; Trigger: wall-clock across the rect); -// the vertical axis is LEVEL (0 at rect bottom, 1 at rect top). -// -// BOUNDS INVARIANT (FA2). EVERY vertex of EVERY polyline is clamped inside the canvas: -// x in [area.x, area.right()-1], y in [area.y, area.bottom()-1] (half-open rect convention). -// No node and no drawn segment ever exceeds the canvas — paint-time clipping of handles is no -// longer needed (and never fires) in the shell. -// -// FA2 CONTRACT CHANGE — WAVE B SHELL AUTHOR, READ THIS: -// * The EnvNode enum is UNCHANGED (same node set, same draggable set — Origin + ReleaseStart -// remain the only non-draggable anchors). -// * ALL vertices are now in-bounds (see above). The shell's previous "skip handle when -// v.x >= waveArea.right()" clip is dead code: ReleaseEnd (Gate) and FadeOutStart/LengthEnd -// (Trigger, at full length / zero fade-out) now land at area.right()-1 and MUST get handles. -// * Gate's x-axis is SCHEMATIC, not PCM-aligned: the timed region is scaled to the param -// domain (4 x kGateStageMaxSeconds), the sustain reserve is a fixed width, and every -// segment carries a kGateNodeSepPx pixel base. The Gate curve does NOT line up with the -// waveform under it — do not label it as if it did. Trigger's x-axis IS still PCM-aligned. -// * Gate nodes never coincide (min-separation, above), so every Gate handle is individually -// grabbable in every state. nodeAtPoint (envelope_edit) resolves to the NEAREST node within -// the grab radius with a draw-order tie-break; the tie-break only matters for the one -// remaining coincidence, Trigger's zero-fade-out (FadeOutStart overlays LengthEnd at the -// right edge and wins the tie, so the fade can be dragged open from zero). -// -// DELIBERATELY ENGINE-FREE (house pattern — param_slider does the same). It does NOT depend on -// sample_map / sampler_core (which would drag bank_book / wav_codec in). The shell reads the -// zone's AdsrSeconds / TriggerParams and packs them into the small AmpEnvelope view struct here. -// AHDSR times are wall-clock SECONDS (rate-free, matching the stored domain — Daniel's no- -// hardcoded-rate ruling); Trigger fades are FRACTIONS of the play span. The one rate-bound input -// is the total sample duration in seconds, which the shell resolves once from the live rate and -// the frame count and passes in — this module never sees a sample rate. -// -// It reuses editor_geometry's Rect + contains(), the one shared geometry idiom. +// envelope_overlay.h — amp-envelope -> polyline geometry for the Sample-view envelope overlay. +// Engine-free by design (no sample_map/sampler_core dependency); mirror of waveform_view / +// param_slider. The shell packs the zone's AdsrSeconds/TriggerParams into AmpEnvelope and draws +// the polyline plus a handle at each node (envelope_edit does the hit-test). #pragma once @@ -64,166 +12,96 @@ namespace reasampler::instrument::ui { -// The play mode the overlay draws — a LOCAL mirror of sampler_core's PlayMode kept here so the -// geometry module stays engine-free (the shell maps the zone's PlayMode to this). Same two cases. +// Local mirror of sampler_core's PlayMode, kept here so this module stays engine-free. enum class EnvMode { Gate, Trigger }; -// Which breakpoint a polyline vertex / node is. The shell draws a draggable handle at each of -// these; envelope_edit hit-tests against them. Kept in one enum shared by overlay + edit so the -// forward map (draw) and inverse map (edit) name the same nodes. -// -// Gate nodes: Origin -> AttackEnd -> HoldEnd -> DecayEnd(=sustain corner) -> ReleaseStart -// -> ReleaseEnd. The sustain node is DecayEnd (its Y is the sustain level); -// ReleaseStart is a drawing-only plateau-end vertex (the schematic note-off); -// release is edited by dragging ReleaseEnd. -// Trigger nodes: Origin -> FadeInEnd -> FadeOutStart -> LengthEnd(playEnd, level 0). The fade-out -// ramp is the FadeOutStart->LengthEnd segment; LengthEnd is the playEnd terminal. +// Gate nodes: Origin -> AttackEnd -> HoldEnd -> DecayEnd(sustain) -> ReleaseStart -> ReleaseEnd. +// Trigger nodes: Origin -> FadeInEnd -> FadeOutStart -> LengthEnd(playEnd). +// Shared by envelope_overlay (forward/draw map) and envelope_edit (inverse/edit map). enum class EnvNode { - Origin, // t=0, level 0 (both modes) — not draggable (fixed anchor) - AttackEnd, // Gate: top of the attack ramp (level 1) — X sets attackSeconds - HoldEnd, // Gate: end of the hold plateau (level 1) — X sets holdSeconds - DecayEnd, // Gate: decay settles to sustain — the SUSTAIN node (X sets decaySeconds, - // Y sets sustainLevel) - ReleaseStart, // Gate: end of the sustain plateau / start of the release (sustain level) — - // a DRAWING vertex only, not a draggable handle (release is edited at - // ReleaseEnd; this vertex sits a fixed sustain-plateau width right of - // DecayEnd — the schematic note-off — Y = sustain level) - ReleaseEnd, // Gate: end of the release tail (level 0) — X sets releaseSeconds - FadeInEnd, // Trigger: top of the fade-in (level 1) — X sets fadeInFraction - FadeOutStart, // Trigger: end of the unity plateau / start of the fade-out (level 1) — - // X sets fadeOutFraction - LengthEnd, // Trigger: the playEnd terminal / %-length (level 0) — X sets lengthFraction + Origin, // t=0, level 0 — not draggable + AttackEnd, // Gate: attack ramp top — sets attackSeconds + HoldEnd, // Gate: hold plateau end — sets holdSeconds + DecayEnd, // Gate: decay settles to sustain — sets decaySeconds (X) and sustainLevel (Y) + ReleaseStart, // Gate: sustain plateau end — drawing-only, not draggable + ReleaseEnd, // Gate: release tail end — sets releaseSeconds + FadeInEnd, // Trigger: fade-in top — sets fadeInFraction + FadeOutStart, // Trigger: fade-out start — sets fadeOutFraction + LengthEnd, // Trigger: playEnd terminal — sets lengthFraction }; -// The amp-envelope params the overlay draws — the small view struct the shell packs from the -// zone's stored AdsrSeconds / TriggerParams. Engine-free by design (no sampler_core include). -// -// Gate fields (SECONDS, wall-clock): attack / hold / decay / release; sustain is a LEVEL 0..1. -// These map 1-to-1 with the stored AdsrSeconds fields — no conversion required. -// -// Trigger fields (FRACTIONS of play): fadeIn / fadeOut as a fraction of the played span; -// lengthFraction is the played span as a fraction of the -// post-start sample length (matching TriggerParams). -// -// TRIGGER SEAM — CONVERSION REQUIRED ON BOTH PATHS (Wave 2 shell author, read this): -// TriggerParams (sampler_core.h) stores Trigger fades as SOURCE FRAMES: -// fadeInFrames (int64_t) — 0->1 ramp length in source frames -// fadeOutFrames (int64_t) — 1->0 ramp length in source frames -// AmpEnvelope stores them as FRACTIONS of the played span: -// fadeInFraction = fadeInFrames / playLengthFrames -// fadeOutFraction = fadeOutFrames / playLengthFrames -// where playLengthFrames = round(lengthFraction * (frameCount - startFrame)). -// This is a NON-TRIVIAL derived view — NOT a direct field copy. The shell owes a -// converter on BOTH directions: -// PACK (draw): frames -> fraction (TriggerParams -> AmpEnvelope, needs frameCount + rate) -// UNPACK (commit): fraction -> frames (AmpEnvelope -> TriggerParams, same inputs) -// lengthFraction maps 1-to-1 with TriggerParams::lengthFraction and needs no conversion. -// -// Unused fields for the active mode are ignored. +// Amp-envelope params the overlay draws. Trigger's fadeIn/fadeOutFraction are derived from +// TriggerParams' frame counts, not a direct field copy — see the trigger_seam gotcha in +// core/instrument/CLAUDE.md. struct AmpEnvelope { EnvMode mode = EnvMode::Gate; - // Gate (AHDSR), seconds + a dimensionless sustain level. + // Gate (AHDSR): seconds, plus a dimensionless sustain level. double attackSeconds = 0.003; double holdSeconds = 0.0; double decaySeconds = 0.0; double sustainLevel = 1.0; double releaseSeconds = 0.060; - // Trigger, fractions of the play span (fadeIn/fadeOut) and of the post-start length. - // NOTE: fadeInFraction/fadeOutFraction are DERIVED from TriggerParams::fadeInFrames/ - // fadeOutFrames — see the TRIGGER SEAM note above. A converter is owed on both the - // pack (draw) and unpack (commit) paths; these fields are NOT a direct TriggerParams copy. - double lengthFraction = 1.0; // (0,1] of the post-start span that plays (1-to-1 with TriggerParams) - double fadeInFraction = 0.0; // 0->1 ramp as a fraction of the played span (DERIVED — see above) - double fadeOutFraction = 0.0; // 1->0 ramp as a fraction of the played span (DERIVED — see above) + // Trigger: fractions of the played span. + double lengthFraction = 1.0; + double fadeInFraction = 0.0; + double fadeOutFraction = 0.0; }; -// One polyline vertex: a pixel point plus which node it is. The shell draws a line through the -// points in order (the amp curve) and a draggable handle at each vertex whose node is not Origin. -// Level is carried alongside (0..1) for callers that want to label/inspect; it is redundant with y. +// One polyline vertex: pixel point plus which node it is. level is redundant with y, carried for +// inspection. struct EnvVertex { EnvNode node = EnvNode::Origin; - int x = 0; // pixel x inside the overlay rect - int y = 0; // pixel y inside the overlay rect (top = level 1, bottom = level 0) - double level = 0.0; // 0..1, the vertex's amplitude (redundant with y; for inspection) + int x = 0; + int y = 0; + double level = 0.0; bool operator==(const EnvVertex& o) const { return node == o.node && x == o.x && y == o.y && level == o.level; } }; -// The fraction of the canvas width RESERVED for the Gate sustain-plateau display (FA2). The -// plateau is a fixed-width schematic region between DecayEnd and ReleaseStart; the remaining -// width is the "timed" region A/H/D/R map onto at the schematic param-domain scale. One -// constant shared by the forward map (here) and the inverse map (envelope_edit). +// Fraction of canvas width reserved for the Gate sustain-plateau display; the remaining width +// carries A/H/D/R at the param-domain scale. Shared with envelope_edit. inline constexpr double kGateSustainDisplayFraction = 0.15; -// The minimum pixel separation between consecutive Gate polyline nodes: every Gate segment gets -// this many px as a base, PLUS its time-proportional extent, so zero-duration stages (tier-0 -// defaults: hold 0, decay 0) still render as distinct, individually grabbable handles. Chosen -// larger than envelope_edit's kNodeGrabRadius (6) so a click dead-on a node can never tie with -// its neighbour. Shared by the forward map and the drag inverse. +// Minimum pixel separation between consecutive Gate nodes, so zero-duration stages (tier-0 +// defaults) still render as distinct, grabbable handles. Larger than envelope_edit's grab +// radius (6) so a click can never tie between neighbours. inline constexpr int kGateNodeSepPx = 8; -// The Gate schematic's per-stage time domain (seconds): the timed region represents the four -// stages end-to-end at this maximum each (4 x this total). MIRRORS the shell's stage-slider -// ceiling (kEnvTimeMaxSeconds in reasampler_editor.cpp) — keep the two equal so a stage at its -// slider max lands exactly at the canvas edge. Drag safety does NOT depend on this constant -// (param clamps are caller-supplied in envelope_edit); only layout does. +// Gate schematic's per-stage time domain (seconds) — the timed region represents four stages +// end-to-end at this max each. Must match the shell's stage-slider ceiling so a maxed slider +// lands exactly at the canvas edge. inline constexpr double kGateStageMaxSeconds = 2.0; -// The pixel width of the Gate timed region: area.width minus the sustain-plateau reserve, -// floored at 1 px so the px<->seconds scale never degenerates for a non-empty area. Returns 0 -// for a zero/negative-width area. Shared by gatePolyline and envelope_edit's gate drag scale. +// Pixel width of the Gate timed region (area width minus the sustain reserve), floored at 1 for +// a non-empty area; 0 for a zero/negative-width area. int gateTimedWidth(const Rect& area); -// Pixels per second of the Gate timed region under the PARAM-DOMAIN scale: the timed width, -// minus the four per-segment kGateNodeSepPx bases and the last in-bounds column, spread over -// 4 x kGateStageMaxSeconds. Independent of the sample's duration. Returns 0 for a -// zero/negative-width area; otherwise > 0 (the usable width floors at 1 px). The ONE px<->sec -// scale shared by the forward map (gatePolyline) and the drag inverse (envelope_edit), so a -// dragged handle tracks the cursor 1:1. +// Pixels per second of the Gate timed region, independent of the sample's actual duration. +// Shared by buildEnvelopePolyline and envelope_edit's drag inverse so a dragged handle tracks +// the cursor 1:1. double gatePxPerSecond(const Rect& area); -// Map an amp envelope to its polyline vertices inside `area`, over a sample of `totalSeconds` -// wall-clock duration. `area` is the waveform rect (left/top inclusive, right/bottom exclusive); -// y maps level 0..1 across [area.bottom()-1 .. area.y] (level 1 at the TOP). The polyline reads -// left-to-right in draw order, Origin first. +// Maps an amp envelope to polyline vertices inside `area` over a sample of `totalSeconds` +// duration. y maps level [0,1] across [area.bottom()-1, area.y] (level 1 at the top); vertices +// are in draw order, Origin first. // -// TIME BASE (FA2). -// * Gate: a bounded schematic, INDEPENDENT of totalSeconds. The canvas splits into a TIMED -// region of gateTimedWidth(area) px — where attack/hold/decay run from t=0 and the release -// ramp runs after the plateau, at the gatePxPerSecond(area) PARAM-DOMAIN scale, each segment -// carrying a kGateNodeSepPx base so consecutive nodes never coincide — plus a FIXED sustain -// plateau of (width - timedWidth) px between DecayEnd and ReleaseStart (the schematic -// note-off). Stages beyond the schematic domain (a stored stage > kGateStageMaxSeconds) -// compress from the RIGHT preserving the minimum gaps, so trailing nodes stay individually -// separated instead of piling on the last column; only a canvas too narrow to hold the -// minimum gaps at all sacrifices separation (in-bounds wins). -// * Trigger: the waveform's exact time base (PCM-aligned). The played span is -// lengthFraction * totalSeconds; fade-in/out are fractions OF that played span. Nodes past -// the played span never appear (FadeOutStart/LengthEnd sit at the played span's right edge). -// -// BOUNDS: every vertex is inside the canvas — x in [area.x, area.right()-1], y in -// [area.y, area.bottom()-1]. Nothing maps past area.right() (the pre-FA2 release tail is gone). A -// degenerate area (zero width/height) or totalSeconds <= 0 yields the two-point flat baseline -// [Origin, end at level 0] so the shell always has a drawable line. Pure — same inputs, same -// polyline. +// Gate's x-axis is a bounded schematic independent of totalSeconds (does NOT line up with the +// waveform under it); Trigger's x-axis is PCM-aligned wall-clock. Every vertex is clamped inside +// the canvas: x in [area.x, area.right()-1], y in [area.y, area.bottom()-1]. A degenerate area +// or totalSeconds <= 0 yields the flat two-point baseline [Origin, end at level 0]. std::vector buildEnvelopePolyline(const AmpEnvelope& env, const Rect& area, double totalSeconds); -// Map a time (seconds) to a pixel x inside `area`: t=0 -> area.x, t=totalSeconds -> -// area.right()-1, linear, CLAMPED on both sides (t < 0 pins to area.x; t past totalSeconds pins -// to area.right()-1 — the in-bounds invariant, FA2). A zero-width area or totalSeconds <= 0 yields -// area.x. Pure — the shared time->x map the Trigger polyline and the node hit-test -// (envelope_edit) use, so the drawn handle and its grab region agree. +// Maps a time (seconds) to a pixel x inside `area`, linear and clamped at both ends. Shared +// with envelope_edit's node hit-test so the drawn handle and its grab region agree. int timeToX(const Rect& area, double totalSeconds, double t); -// Map a level (0..1) to a pixel y inside `area`: level 1 -> area.y, level 0 -> area.bottom()-1 -// (so the full-amplitude line sits at the top edge and silence at the bottom pixel row). level is -// clamped to [0,1]. A zero-height area yields area.y. Pure — the shared level->y map the polyline -// and the node hit-test share. +// Maps a level [0,1] to a pixel y inside `area` (level 1 at the top, 0 at the bottom row), +// clamped. Shared with envelope_edit's node hit-test. int levelToY(const Rect& area, double level); } // namespace reasampler::instrument::ui diff --git a/src/core/instrument/ui/keyboard_strip.cpp b/src/core/instrument/ui/keyboard_strip.cpp index 7b64af4..558217b 100644 --- a/src/core/instrument/ui/keyboard_strip.cpp +++ b/src/core/instrument/ui/keyboard_strip.cpp @@ -14,10 +14,8 @@ int clampNote(int n) { return n; } -// Map a key BOUNDARY in [0, kStripKeyCount] to an x pixel inside a band of the given -// left/width. keyEdge is a boundary (0..128): 0 -> band left, 128 -> band right. Integer -// math, floored — key N's left is keyEdgeToX(N) and its right is keyEdgeToX(N+1), tiling -// adjacent keys/zones without a seam (mirror of embed_strip::keyEdgeToX). +// Maps a key boundary (0..128) to an x pixel. Key N's left is keyEdgeToX(N), right is +// keyEdgeToX(N+1) — tiles adjacent keys/zones without a seam. Mirrors embed_strip::keyEdgeToX. int keyEdgeToX(int bandLeft, int bandWidth, int keyEdge) { if (keyEdge <= 0) return bandLeft; if (keyEdge >= kStripKeyCount) return bandLeft + bandWidth; @@ -37,8 +35,7 @@ StripLayout layoutStrip(int w, int h) { int keyLeftX(const StripLayout& layout, int note) { const Rect& band = layout.keys; const int bandWidth = std::max(0, band.width); - // note is a KEY here (0..127); its left edge is boundary `note`. Callers pass note+1 to - // get a key's right edge, and 128 maps to the band right. + // note is a key (0..127); callers pass note+1 to get its right edge, 128 -> band right. const int edge = note < 0 ? 0 : (note > kStripKeyCount ? kStripKeyCount : note); return keyEdgeToX(band.x, bandWidth, edge); } @@ -59,8 +56,7 @@ int keyAtPoint(const StripLayout& layout, int x, int y) { if (!contains(band, x, y)) return -1; const int bandWidth = std::max(0, band.width); if (bandWidth <= 0) return -1; - // Invert keyEdgeToX: the key whose half-open [leftX, rightX) contains x. Floor-divide - // the pixel offset back to a key; clamp defensively (a point on band.right()-1 maps to 127). + // Inverts keyEdgeToX: the key whose half-open [leftX, rightX) contains x. const int offset = x - band.x; int note = (offset * kStripKeyCount) / bandWidth; return clampNote(note); @@ -69,7 +65,7 @@ int keyAtPoint(const StripLayout& layout, int x, int y) { Rect zoneBarRect(const StripLayout& layout, int lowNote, int highNote) { int lo = clampNote(lowNote); int hi = clampNote(highNote); - if (lo > hi) lo = hi; // defensive: a malformed zone collapses rather than inverts + if (lo > hi) lo = hi; // malformed zone collapses rather than inverts const int leftX = keyLeftX(layout, lo); const int rightX = keyLeftX(layout, hi + 1); return Rect::ltrb(leftX, layout.keys.y, std::max(leftX, rightX), layout.keys.bottom()); @@ -80,8 +76,7 @@ ZoneGrab zoneGrabAt(const StripLayout& layout, int lowNote, int highNote, int x, if (!contains(bar, x, y)) return ZoneGrab::kNone; const int barW = bar.width; - // A narrow bar (< 2*edge) has no body: split at the midpoint, LOW edge wins the tie so - // a click exactly on the midpoint resizes low (deterministic). + // A narrow bar has no body: split at the midpoint, low edge wins the tie. if (barW < 2 * kStripEdgeGrabWidth) { const int mid = bar.x + barW / 2; return x <= mid ? ZoneGrab::kLowEdge : ZoneGrab::kHighEdge; @@ -103,11 +98,7 @@ ZoneBarHit zoneBarAtPoint(const StripLayout& layout, const int* lows, const int* } bool isNaturalKey(int note) { - // Clamp to the valid MIDI range before indexing. const int n = note < 0 ? 0 : (note > kStripKeyCount - 1 ? kStripKeyCount - 1 : note); - // The 12-semitone pattern of natural (white) keys within an octave, starting at C: - // positions 0(C) 2(D) 4(E) 5(F) 7(G) 9(A) 11(B) are natural; - // positions 1(C#) 3(D#) 6(F#) 8(G#) 10(A#) are accidental. static constexpr bool kNatural[12] = { true, // 0 C false, // 1 C# @@ -129,13 +120,8 @@ int resolveDragNote(const StripLayout& layout, int startNote, int dxPixels) { if (dxPixels == 0) return clampNote(startNote); const int bandWidth = std::max(0, layout.keys.width); if (bandWidth <= 0) return clampNote(startNote); // zero-width -> no motion - // Proportional shift: same linear mapping as keyAtPoint/keyEdgeToX so click and drag - // agree across the full strip, even on non-divisible-by-128 widths. The proportional - // key width is (bandWidth / kStripKeyCount) in exact rational arithmetic; rounding to - // the nearest key (half-key drag flips at the key centre) is achieved by adding - // bandWidth/2 to the absolute pixel delta before dividing — identical to the old - // formula except keyWidth is now derived from the same linear map (exact rational) - // rather than the truncated-integer bandWidth/128 that caused drift at the far end. + // Same linear mapping as keyAtPoint/keyEdgeToX (exact rational), not a truncated-integer + // bandWidth/128 key width — that drifted at the far end of the strip. const int half = bandWidth / 2; int shift; if (dxPixels > 0) { diff --git a/src/core/instrument/ui/keyboard_strip.h b/src/core/instrument/ui/keyboard_strip.h index 2f843a3..87533ad 100644 --- a/src/core/instrument/ui/keyboard_strip.h +++ b/src/core/instrument/ui/keyboard_strip.h @@ -1,106 +1,70 @@ -// keyboard_strip.h — PURE layout + hit-test + drag math for the S10 capture-first -// editor's keyboard strip. NO VST3, NO REAPER, NO SWELL/LICE types at the boundary. -// The mirror of editor_geometry / embed_strip / mode_switch: the fiddly rectangle + -// note-mapping arithmetic lives here so it is unit-tested outside the DAW, while the -// editor shell (reasampler_editor.cpp) draws the strip and marshals mouse events into -// these functions. +// keyboard_strip.h — layout + hit-test + drag math for the capture-first editor's +// keyboard strip. Mirror of editor_geometry/embed_strip/mode_switch; the shell draws +// and marshals mouse events into these functions. // -// The strip maps the full 128-key MIDI span across a horizontal band (the same key-span -// idiom embed_strip uses). It serves TWO faces of the S10 editor: -// * the SINGLE-CAPTURE fast path (default): one loaded capture with a ROOT MARKER on -// the strip, click-a-key (or drag the marker) sets the capture's root note; and -// * the opt-in ZONES panel (S10-Z, demoted): each performance zone drawn as a bar over -// the keys it covers, with edge-grab resize handles + a body move-handle so a drag -// sets low/high (edges) or moves the span (body), and a key-click sets the zone root. -// -// All interaction resolves through the pure DRAG-DELTA resolver here: the shell captures -// a grab on WM_LBUTTONDOWN, feeds each WM_MOUSEMOVE's pixel delta back through -// resolveDragNote, and commits the resolved note(s) on WM_LBUTTONUP. Live feedback is the -// shell re-drawing the in-flight note; one coherent edit lands on release. -// -// It reuses the same Rect + contains() as editor_geometry (one shared geometry idiom), -// so this header depends on editor_geometry.h rather than redefining a rectangle type. +// The strip maps the full 128-key MIDI span across a horizontal band (the same idiom +// embed_strip uses) and serves two faces: the single-capture fast path (a root marker, +// click-a-key or drag it to set root) and the opt-in zones panel (each zone drawn as a +// bar with edge-grab resize handles + a body move-handle). #pragma once -#include "core/instrument/ui/editor_geometry.h" // Rect, contains — one shared geometry idiom +#include "core/instrument/ui/editor_geometry.h" // Rect, contains namespace reasampler::instrument::ui { -// The full MIDI key span the strip maps across its width: 128 keys (0..127). Named -// distinctly from embed_strip's kEmbedKeyCount (same value) so the two strips stay -// independent — the editor strip may grow octave labels/metrics the embed strip never does. +// Named distinctly from embed_strip's kEmbedKeyCount (same value) so the two strips +// stay independent. inline constexpr int kStripKeyCount = 128; -// The width (px) of an edge-grab hit region at each end of a zone bar: a drag started -// within this many pixels of the bar's left/right edge resizes that edge; a drag started -// anywhere else on the bar moves the whole span. A zone narrower than 2*this has no body -// move-handle (both edges win their halves) — deliberate: a 1-key zone is all edges. +// Pixel width of a zone bar's edge-grab region. A zone narrower than 2x this has no +// body move-handle (both edges win their halves). inline constexpr int kStripEdgeGrabWidth = 6; -// The strip's regions, derived from the (w x h) band the shell allots it. The keys band -// takes the whole area today (a future octave-label lane can carve a sub-band here without -// changing callers). Clamped so a degenerate (tiny/zero) size never yields an inverted rect. +// The keys band takes the whole strip area today; clamped so a degenerate size never +// yields an inverted rect. struct StripLayout { - Rect keys; // the key band: the 128-key span maps linearly across keys.width + Rect keys; }; -// Divide a (w x h) strip area into its regions. Pure: same inputs -> same layout. A zero or -// negative size yields empty rects (no inversion). +// Divide a (w x h) strip area into its regions. Pure. StripLayout layoutStrip(int w, int h); -// The x pixel (inside the keys band) of the LEFT edge of key `note` (0..127). The 128-key -// span maps linearly across keys.width; key N occupies the half-open pixel range -// [keyLeftX(N), keyLeftX(N+1)). Notes are clamped to [0,127]; note==128 maps to the band's -// right edge (so a key's right edge is keyLeftX(note+1)). Pure. +// x pixel of the LEFT edge of key `note` (0..127) under the linear 128-key map; key N +// occupies [keyLeftX(N), keyLeftX(N+1)). note==128 maps to the band's right edge. int keyLeftX(const StripLayout& layout, int note); -// The half-open pixel rect of a single key `note` (0..127): [keyLeftX(note), -// keyLeftX(note+1)) horizontally, the full keys-band height. A malformed (out-of-range) -// note clamps to [0,127]. Pure. +// Half-open rect of a single key `note`, clamped to [0,127]. Rect keyRect(const StripLayout& layout, int note); -// The rect of the ROOT MARKER for the single-capture fast path: the key cell of `rootNote`, -// drawn as a highlighted key. Equivalent to keyRect(layout, rootNote) — a named entry point -// so the shell's intent (this is the root marker, not just any key) reads at the call site, -// and so a future marker shape (a triangle over the key) has one place to change. Pure. +// Root-marker rect for the single-capture fast path; equivalent to +// keyRect(layout, rootNote) but named so the intent reads at the call site. Rect rootMarkerRect(const StripLayout& layout, int rootNote); -// The MIDI note a point (x, y) lands on, or -1 for a point outside the keys band. Backs -// click-to-set-root (single capture) and click-a-key-sets-zone-root (zones). Pure. +// MIDI note a point (x, y) lands on, or -1 outside the keys band. int keyAtPoint(const StripLayout& layout, int x, int y); -// The horizontal sub-rect of the keys band for a zone spanning [lowNote, highNote] -// (inclusive): [keyLeftX(low), keyLeftX(high+1)) horizontally, the full band height. Notes -// clamp to [0,127] and low clamps to <= high, so a malformed zone yields an in-band -// (possibly zero-width) rect, never an inverted one. Mirrors embed_strip::zoneSegmentRect. -// Pure. +// Horizontal sub-rect for a zone spanning [lowNote, highNote] inclusive. Notes clamp to +// [0,127] and low clamps to <= high, so a malformed zone never yields an inverted rect. Rect zoneBarRect(const StripLayout& layout, int lowNote, int highNote); -// Which part of a zone bar a grab landed on. The shell uses this to decide what a drag -// edits: an edge resizes that boundary; the body moves the whole span; none means the grab -// missed the bar entirely (the shell may treat that as a key-click to set the root, or as a -// deselect). +// Which part of a zone bar a grab landed on: an edge resizes that boundary, the body +// moves the whole span, kNone means the grab missed the bar. enum class ZoneGrab { - kNone, // the point is not on this zone's bar - kLowEdge, // within kStripEdgeGrabWidth of the bar's LEFT edge -> resize low - kHighEdge, // within kStripEdgeGrabWidth of the bar's RIGHT edge -> resize high - kBody, // on the bar but not an edge -> move the whole span + kNone, + kLowEdge, + kHighEdge, + kBody, }; -// Classify a grab at (x, y) against ONE zone's bar (low..high). Returns kNone when the -// point is off the bar (or off the keys band). On the bar: kLowEdge/kHighEdge when within -// kStripEdgeGrabWidth of that edge, else kBody. A narrow bar (< 2*kStripEdgeGrabWidth) -// resolves the near half to each edge (no body). The LOW edge wins a tie at the exact -// midpoint of a narrow bar (deterministic). Pure. +// Classify a grab at (x, y) against one zone's bar. A narrow bar (< 2*kStripEdgeGrabWidth) +// resolves the near half to each edge (no body); the low edge wins a tie at the exact +// midpoint. ZoneGrab zoneGrabAt(const StripLayout& layout, int lowNote, int highNote, int x, int y); -// The zone (index into `lows`/`highs`, draw order) whose bar a grab at (x, y) lands on, -// plus which part of it, or {-1, kNone} for a point off every bar. First covering zone in -// draw order wins (first-match, mirroring the core's Keymap::resolve + embed_strip). The -// arrays are parallel (lows[i]/highs[i] is zone i's inclusive range); `count` is their -// length. Pure — no host containers at the boundary (a raw pointer pair, like -// embed_strip::zoneAtPoint). +// Zone (index into the parallel `lows`/`highs` arrays, draw order) whose bar a grab +// lands on, plus which part, or {-1, kNone} for a miss. First covering zone in draw +// order wins. struct ZoneBarHit { int zoneIndex = -1; ZoneGrab grab = ZoneGrab::kNone; @@ -108,24 +72,13 @@ struct ZoneBarHit { ZoneBarHit zoneBarAtPoint(const StripLayout& layout, const int* lows, const int* highs, int count, int x, int y); -// Resolve a drag to a new MIDI note. Given the note the grabbed field held at grab time -// (`startNote`) and the horizontal pixel delta since grab (`dxPixels`), returns the note -// the field should now hold: startNote shifted by round(dxPixels / keyWidth), clamped to -// [0,127]. keyWidth is derived from the layout (band width / 128); a zero-width band pins -// the result to startNote (no motion). This is the single arithmetic behind edge-resize, -// body-move (apply to both edges with the SAME delta so the span is preserved), and -// root-marker drag. Pure — rounding is to the nearest key so a half-key drag flips at the -// key centre. Returns startNote unchanged for dxPixels==0. +// Resolves a drag to a new MIDI note: `startNote` shifted by round(dxPixels / keyWidth), +// clamped to [0,127]. The one arithmetic behind edge-resize, body-move (apply to both +// edges with the same delta to preserve span), and root-marker drag. int resolveDragNote(const StripLayout& layout, int startNote, int dxPixels); -// Returns true when `note` (0..127) is a NATURAL (white) key in standard 12-tone equal -// temperament; false when it is an ACCIDENTAL (black) key. Notes out of the [0,127] -// range are clamped to [0,127] before classification (i.e. this never throws/UBs on a -// bad input). The 12 semitone positions within an octave: -// Natural (white): 0(C) 2(D) 4(E) 5(F) 7(G) 9(A) 11(B) -// Accidental (black): 1(C#) 3(D#) 6(F#) 8(G#) 10(A#) -// Used by the shell to overlay the two-tone bright/dark piano-key pattern over the -// pastel spectral fill (S-VIEW-7). Pure — no layout required, no host types. +// True when `note` (clamped to [0,127]) is a natural (white) key in 12-tone equal +// temperament; false for an accidental (black) key. bool isNaturalKey(int note); } // namespace reasampler::instrument::ui diff --git a/src/core/instrument/ui/knob_deck.cpp b/src/core/instrument/ui/knob_deck.cpp index 6eae276..43bb8a9 100644 --- a/src/core/instrument/ui/knob_deck.cpp +++ b/src/core/instrument/ui/knob_deck.cpp @@ -37,7 +37,7 @@ DeckGroupLayout layoutGroup(const DeckGroupDesc& g, const Rect& box) { const int innerLeft = box.x + kDeckGroupPadX; const int innerRight = box.right() - kDeckGroupPadX; - // Caption row: text left, compact toggle right-anchored (r11 — the not-full-width home). + // Caption row: text left, compact toggle right-anchored. out.caption = Rect::ltrb(innerLeft, captionTop, innerRight, captionTop + kDeckCaptionH); if (g.captionToggle.id >= 0) { const int segW = g.captionToggle.segWidth; diff --git a/src/core/instrument/ui/knob_deck.h b/src/core/instrument/ui/knob_deck.h index 2d07b70..22290e9 100644 --- a/src/core/instrument/ui/knob_deck.h +++ b/src/core/instrument/ui/knob_deck.h @@ -1,27 +1,18 @@ -// knob_deck.h — PURE knob-deck layout + hit-test for the r11 Sample-face recomposition -// (Wave B, FB1). NO VST3, NO REAPER, NO SWELL/LICE types at the boundary, and — like -// param_slider — NO engine types: cells and toggles carry opaque shell-owned control ids. -// The mirror of action_bar / param_slider: the fiddly group-box / caption-row / cell-grid -// arithmetic lives here, unit-tested outside the DAW, while the editor shell draws each -// group (fence, caption, compact toggles, knobs) through the L1 kit and routes clicks/drags -// via the hit-test. The KNOB PRIMITIVE itself (value<->needle-angle, vertical drag) is -// param_slider's (FA4); a knob cell here is just a rect — the shell composes the two. +// knob_deck.h — knob-deck layout + hit-test for the Sample-face knob deck. Engine-free +// like param_slider: cells and toggles carry opaque shell-owned control ids. Mirror of +// action_bar/param_slider; the knob primitive itself (value<->needle-angle, drag) is +// param_slider's — a knob cell here is just a rect the shell composes it into. // -// THE DECK (CONTEXT.md §S-VIEW r11). A horizontal run of FENCED GROUPS, left -> right, each -// a hairline-bordered bg/panel box with a CAPTION ROW (micro-caps caption left; the group's -// compact mode toggle right-anchored IN the caption row — this is where the not-full-width -// toggles live) over a KNOB ROW of fixed 48x58 cells (28px knob centered, 12px label band -// beneath). A group may additionally place one 18px-tall two-segment toggle IN the knob row -// after its cells (the VOICE group's Retrig|Legato — same Mono/Stereo segment grammar, -// vertically centered). Groups that must keep stable geometry across a mode flip reserve -// blank cells (id -1): the AMP ENVELOPE group always spans 5 cells so Gate<->Trigger never -// reflows its neighbours. +// The deck is a horizontal run of fenced groups, left->right, each a bordered box with a +// caption row (caption left, the group's compact mode toggle right-anchored) over a knob +// row of fixed cells (knob centered, label band beneath). A group may also place one +// two-segment toggle in the knob row after its cells. Groups that must keep stable +// geometry across a mode flip reserve blank cells (id -1) so a mode flip never reflows +// neighbouring groups. // -// WRAP (deterministic): groups place left-to-right with kDeckGroupGap between; a group that -// does not fit the remaining width starts a new deck row (whole groups only, never split). -// The first group of a row always places even if wider than the row (degenerate width). -// deckHeight() exposes the resulting height so the shell can bottom-anchor the deck band and -// give the ELASTIC HERO the rest (r11 band order). +// Wrap is deterministic: groups place left-to-right with kDeckGroupGap between; a group +// that does not fit the remaining width starts a new row (whole groups only, never +// split); the first group of a row always places even if wider than the row. #pragma once @@ -31,7 +22,7 @@ namespace reasampler::instrument::ui { -// Fixed deck metrics (spec r11), exposed so the shell and tests agree. +// Fixed deck metrics, exposed so the shell and tests agree. inline constexpr int kDeckCellW = 48; // one knob cell inline constexpr int kDeckCellH = 58; inline constexpr int kDeckKnobSize = 28; // knob diameter inside the cell @@ -55,9 +46,8 @@ struct DeckToggleDesc { }; // One fenced group, in deck order. `cellIds` are the knob cells left-to-right; an id of -1 -// is a RESERVED BLANK cell (geometry held, never hit — the AMP ENVELOPE Trigger face). -// `captionWidth` is the px the shell reserves for the caption text (this module does not -// measure text — the house constant-metrics pattern). +// is a reserved blank cell (geometry held, never hit). `captionWidth` is the px the shell +// reserves for the caption text (this module does not measure text). struct DeckGroupDesc { int id = 0; // shell group id (opaque here) int captionWidth = 60; @@ -96,21 +86,20 @@ struct DeckLayout { int height = 0; // rowCount * kDeckGroupH + (rowCount-1) * kDeckRowGap; 0 for no groups }; -// The width of one group box: the wider of its caption row (caption + gap + toggle) and its -// knob row (cells + gap + row toggle), plus the horizontal padding. Pure. +// Width of one group box: the wider of its caption row (caption + gap + toggle) and its +// knob row (cells + gap + row toggle), plus horizontal padding. int deckGroupWidth(const DeckGroupDesc& g); -// The number of deck rows the groups occupy at `availWidth` under the greedy whole-group -// wrap (a group that does not fit the remaining row width starts a new row; the first group -// of a row always places). 0 for an empty group list. Pure — the wrap is deterministic. +// Number of deck rows the groups occupy at `availWidth` under the greedy whole-group wrap. +// 0 for an empty list. int deckRowCount(const std::vector& groups, int availWidth); -// The total deck height at `availWidth` (rows * kDeckGroupH + inter-row gaps). 0 for an -// empty list. The shell bottom-anchors a band of exactly this height. Pure. +// Total deck height at `availWidth` (rows * kDeckGroupH + inter-row gaps). The shell +// bottom-anchors a band of exactly this height. int deckHeight(const std::vector& groups, int availWidth); -// Lay the groups out from (left, top) within `availWidth`, wrapping per deckRowCount's rule. -// Every rect is absolute. Pure — same inputs, same layout. +// Lays the groups out from (left, top) within `availWidth`, wrapping per deckRowCount's +// rule. Every rect is absolute. DeckLayout layoutDeck(const std::vector& groups, int left, int top, int availWidth); @@ -124,10 +113,9 @@ struct DeckHit { int segment = -1; // 0/1 for a toggle hit; -1 otherwise }; -// The deck element a point lands on: a knob CELL (the whole 48x58 cell — friendlier than the -// bare knob circle; the shell anchors the vertical drag wherever the grab lands), a caption- -// toggle segment, or a row-toggle segment. Blank cells (id -1) and everything else miss. -// Pure — the shell's routing entry point. +// The deck element a point lands on: a knob cell (the whole cell, not just the knob +// circle — the shell anchors the vertical drag wherever the grab lands), a caption-toggle +// segment, or a row-toggle segment. Blank cells (id -1) and everything else miss. DeckHit hitTestDeck(const DeckLayout& layout, int x, int y); } // namespace reasampler::instrument::ui diff --git a/src/core/instrument/ui/param_slider.cpp b/src/core/instrument/ui/param_slider.cpp index 6aa17cc..0c50889 100644 --- a/src/core/instrument/ui/param_slider.cpp +++ b/src/core/instrument/ui/param_slider.cpp @@ -1,5 +1,4 @@ -// param_slider.cpp — see param_slider.h. PURE control-surface geometry for the S12/S15/S16 -// editor parameter panel. No host types; only the shared Rect + contains(). +// param_slider.cpp — see param_slider.h. Pure control-surface geometry; no host types. #include "core/instrument/ui/param_slider.h" @@ -10,7 +9,7 @@ namespace reasampler::instrument::ui { -using util::clamp01; // the ONE unit-interval clamp (Q-W1, T4-24) +using util::clamp01; std::vector layoutControls(const Rect& panel, const std::vector& controls) { @@ -83,7 +82,7 @@ double valueAtPoint(const Rect& control, int x) { return static_cast(x - track.x) / static_cast(span); } -// --- Radial knob (Wave A FA4) --------------------------------------------------------- +// --- Radial knob ----------------------------------------------------------------------- namespace { diff --git a/src/core/instrument/ui/param_slider.h b/src/core/instrument/ui/param_slider.h index 0720388..df9e14d 100644 --- a/src/core/instrument/ui/param_slider.h +++ b/src/core/instrument/ui/param_slider.h @@ -1,180 +1,141 @@ -// param_slider.h — PURE control-surface layout + hit-test + value<->pixel mapping for the -// S12/S15/S16 editor parameter panel. NO VST3, NO REAPER, NO SWELL/LICE types at the -// boundary, and — deliberately — NO sampler_core / sample_map engine types either. The -// mirror of keyboard_strip / waveform_view / mode_switch: the fiddly slider-track and -// toggle-segment arithmetic lives here, unit-tested outside the DAW, while the editor shell -// draws each row (label + track/segments + handle) and routes clicks/drags into these -// functions, owning the control-id -> engine-param binding + the value DOMAIN mapping. +// param_slider.h — control-surface layout + hit-test + value<->pixel mapping for the +// editor parameter panel. Engine-free by design (no sampler_core/sample_map). Mirror of +// keyboard_strip/waveform_view/mode_switch; the shell draws each row and routes +// clicks/drags into these functions, owning the control-id -> engine-param binding and +// the value domain mapping. // -// WHY IT EXISTS (S12 + the S15/S16 control surfaces deferred here). The setup / Zones surface -// grows a stack of parameter controls: the S15 play-mode toggle (Gate|Trigger), the AHDSR -// amp-envelope sliders (attack/hold/decay/sustain/release), the Trigger %-length + fade -// controls, the S16 Varispeed|Preserve engine toggle, and the AD pitch-envelope -// enable/attack/decay/depth. They are three shapes — a two-segment TOGGLE, a horizontal -// SLIDER, and (Wave A FA4) a radial KNOB with a needle indicator and vertical-drag value -// mapping — laid out as a vertical stack of fixed-height rows. This module lays out that -// stack and maps a control's NORMALIZED value (0..1) to/from its handle pixel / needle -// angle; the shell converts each control's engine value (frames, seconds, a fraction, a -// signed semitone depth) to/from that 0..1 with its own domain knowledge (this module stays -// engine-free so it tests without the audio core). -// -// It reuses editor_geometry's Rect + contains() (one shared geometry idiom). +// Controls are one of three shapes — a two-segment Toggle, a horizontal Slider, or a +// radial Knob with a needle and vertical-drag mapping — laid out as a vertical stack of +// fixed-height rows. This module maps a control's normalized value (0..1) to/from its +// handle pixel / needle angle; the shell converts each control's engine value (frames, +// seconds, a fraction, a signed semitone depth) to/from that 0..1. #pragma once #include -#include "core/instrument/ui/editor_geometry.h" // Rect, contains — one shared geometry idiom +#include "core/instrument/ui/editor_geometry.h" // Rect, contains namespace reasampler::instrument::ui { // Fixed control-panel metrics, exposed so the shell and tests agree. -inline constexpr int kControlRowHeight = 22; // one control row (incl. its inter-row gap) -inline constexpr int kControlRowGap = 4; // vertical gap below each row -inline constexpr int kControlLabelWidth = 92; // the label column at the row's left -inline constexpr int kSliderHandleWidth = 8; // the draggable slider handle width (px) -inline constexpr int kToggleSegments = 2; // a toggle is always two segments +inline constexpr int kControlRowHeight = 22; +inline constexpr int kControlRowGap = 4; +inline constexpr int kControlLabelWidth = 92; +inline constexpr int kSliderHandleWidth = 8; +inline constexpr int kToggleSegments = 2; -// A control is one of three shapes. Toggle = a two-segment selector (the active segment -// highlights); Slider = a horizontal track with a draggable handle over a 0..1 value; -// Knob = a radial dial with a needle indicator over a 0..1 value, dragged VERTICALLY -// (up = increase). +// Toggle = two-segment selector (active segment highlights); Slider = horizontal track +// with a draggable handle over a 0..1 value; Knob = radial dial with a needle, dragged +// vertically (up = increase). enum class ControlKind { Toggle, Slider, Knob }; -// One control the shell places in the panel, in stack order. `id` is the shell's own control -// identifier (an int the shell casts from its ControlId enum) returned by the hit-test so the -// shell routes the interaction to the right engine param — this module never interprets it. +// One control the shell places in the panel, in stack order. `id` is the shell's own +// control identifier, returned by the hit-test so the shell routes to the right engine +// param — this module never interprets it. struct ControlDesc { int id = 0; ControlKind kind = ControlKind::Slider; }; -// The laid-out geometry of one control row: its full row rect plus the interactive sub-rect -// (the track for a Slider, the whole control area for a Toggle — the shell splits a Toggle -// into segments via toggleSegmentRect). `index` is the control's position in the stack. +// Laid-out geometry of one control row: full row rect plus the interactive sub-rect (the +// track for a Slider, the whole control area for a Toggle — the shell splits a Toggle +// into segments via toggleSegmentRect). struct ControlRow { int id = 0; ControlKind kind = ControlKind::Slider; - Rect row; // the full row (label column + control column) - Rect label; // the label column at the left - Rect control; // the control column to the right of the label (track / toggle area) + Rect row; + Rect label; + Rect control; }; -// Lay out `controls` as a vertical stack of fixed-height rows inside `panel`, top-down. Each -// row is kControlRowHeight tall with kControlRowGap below it; the label column takes the left -// kControlLabelWidth (clamped so it never exceeds the panel), the control column the rest. A -// row whose top falls past the panel bottom is still returned (the shell clips at paint / -// suppresses it) so the stack geometry is deterministic regardless of panel height. An empty -// control list or a degenerate panel yields an empty vector. Pure. +// Lays out `controls` as a vertical stack of fixed-height rows inside `panel`, top-down. +// Label column takes the left kControlLabelWidth (clamped to the panel), control column +// the rest. A row past the panel bottom is still returned (the shell clips/suppresses it) +// so stack geometry is deterministic regardless of panel height. std::vector layoutControls(const Rect& panel, const std::vector& controls); -// The rect of segment `seg` (0..kToggleSegments-1) within a toggle control's `control` rect, -// splitting it into kToggleSegments equal segments left-to-right (the last absorbs any width -// remainder, mirror of mode_switch's segment split). An out-of-range segment or a degenerate -// control rect yields an empty rect. Pure. +// Rect of segment `seg` within a toggle's `control` rect, splitting it into +// kToggleSegments equal segments left-to-right (last absorbs any width remainder). Rect toggleSegmentRect(const Rect& control, int seg); -// The toggle segment a point lands on within a toggle control's `control` rect, or -1 for a -// miss (outside the control area). Pure. int toggleSegmentHitTest(const Rect& control, int x, int y); -// The slider track sub-rect inside a slider control's `control` rect: the control inset so the -// handle (kSliderHandleWidth) stays fully within the control at value 0 and 1 (a half-handle -// margin at each end). The handle CENTER ranges across [track.x, track.right()] as the value -// ranges [0,1]. The shell draws the track fill + handle here. A degenerate control yields an -// empty rect. Pure. +// Slider track sub-rect inside `control`: inset so the handle stays fully within the +// control at value 0 and 1. The handle center ranges across [track.x, track.right()] as +// the value ranges [0,1]. Rect sliderTrackRect(const Rect& control); -// The handle rect for a slider at normalized `value` (clamped to [0,1]) within `control`: a -// kSliderHandleWidth-wide bar centered at the value's position along sliderTrackRect. A -// degenerate control yields an empty rect. Pure — the inverse of valueAtPoint. +// Handle rect for a slider at normalized `value` (clamped to [0,1]). Rect sliderHandleRect(const Rect& control, double value); -// Map a point x to a normalized slider value [0,1] within `control` (the handle-center range). -// x at/left of the track start -> 0; at/right of the end -> 1; linear between. A degenerate -// track (zero movable span) -> 0. Pure — the inverse of sliderHandleRect's position map; the -// shell converts the returned 0..1 into its engine domain (frames/seconds/fraction/semitones). +// Maps a point x to a normalized slider value [0,1]: at/left of track start -> 0, at/right +// of end -> 1, linear between. Inverse of sliderHandleRect's position map. double valueAtPoint(const Rect& control, int x); -// --- Radial knob (Wave A FA4) -------------------------------------------------------------- +// --- Radial knob --------------------------------------------------------------------- // -// Angle convention: DEGREES CLOCKWISE FROM 12 O'CLOCK, matching a clock face in screen -// coordinates (y grows downward): 0 = 12 o'clock (up), 90 = 3 o'clock (right), 180 = 6 -// o'clock (down), 270 = 9 o'clock (left). The value arc sweeps CLOCKWISE from startDeg -// (value 0) to endDeg (value 1); an endDeg at-or-behind startDeg wraps +360, so equal -// angles mean a full 360° sweep. +// Angle convention: degrees clockwise from 12 o'clock (screen coords, y grows downward). +// The value arc sweeps clockwise from startDeg (value 0) to endDeg (value 1); an endDeg +// at-or-behind startDeg wraps +360. // -// The DEFAULT arc is the conventional 7→5 o'clock layout: min at 7 o'clock (210°) sweeping -// clockwise 300° around to max at 5 o'clock (150°), leaving a symmetric 60° dead arc at the -// bottom. The 50% (midpoint) value lands at 12 o'clock (0°/360°) — straight up. The angles -// are PARAMETERS, not hardcoded — the shell sets the final sweep when the parallel layout -// spec lands. -inline constexpr double kKnobArcStartDeg = 210.0; // value 0 — 7 o'clock -inline constexpr double kKnobArcEndDeg = 150.0; // value 1 — 5 o'clock (clockwise wrap) +// Default arc: 7 o'clock (210°) sweeping clockwise 300° to 5 o'clock (150°), leaving a +// symmetric 60° dead arc at the bottom; the 50% value lands at 12 o'clock. Angles are +// parameters, not hardcoded. +inline constexpr double kKnobArcStartDeg = 210.0; +inline constexpr double kKnobArcEndDeg = 150.0; -// Default vertical-drag sensitivity: pixels of upward drag for one full 0->1 sweep. +// Pixels of upward drag for one full 0->1 sweep. inline constexpr int kKnobDragRangePixels = 128; -// The configurable value arc of a knob. Defaults to the 7->5 o'clock reading above. struct KnobArc { double startDeg = kKnobArcStartDeg; double endDeg = kKnobArcEndDeg; }; -// A knob's circle within its control cell: center + radius in pixel space (doubles so the -// shell rounds once, at draw time). radius == 0 marks a degenerate cell. +// A knob's circle within its control cell: center + radius (doubles so the shell rounds +// once, at draw time). radius == 0 marks a degenerate cell. struct KnobGeometry { double centerX = 0.0; double centerY = 0.0; double radius = 0.0; }; -// A pixel-space point (the needle endpoint the shell draws to). struct KnobPoint { double x = 0.0; double y = 0.0; }; -// The knob circle inscribed in `cell`, centered, radius = half the smaller dimension. A -// degenerate cell yields radius 0. CONTRACT: the shell MUST pass `row.control` (the full -// control column) both when drawing and when hit-testing — `controlAtPoint` always uses -// `r.control` as the cell, so the draw cell and hit cell must be the same. If the shell -// wants to draw a smaller circle it must center it within `row.control` and accept that the -// hit area is the larger column-inscribed circle. Pure. +// Knob circle inscribed in `cell`, centered, radius = half the smaller dimension. The +// shell must pass `row.control` both when drawing and hit-testing — controlAtPoint always +// uses `r.control` as the cell, so draw cell and hit cell must agree. KnobGeometry computeKnob(const Rect& cell); -// True if (x, y) falls strictly inside the knob circle (boundary exclusive, matching the -// module's half-open Rect convention). A degenerate knob (radius <= 0) hits nothing. Pure. +// True if (x, y) falls strictly inside the knob circle (boundary exclusive). bool knobHitTest(const KnobGeometry& knob, int x, int y); -// The clockwise sweep of `arc` in degrees, in (0, 360]: normalized end - start, wrapping -// +360 when the end is at-or-behind the start (default arc -> 300). Pure. +// Clockwise sweep of `arc` in degrees, in (0, 360]: normalized end - start, wrapping +360 +// when the end is at-or-behind the start (default arc -> 300). double knobSweepDeg(const KnobArc& arc); -// The needle angle for normalized `value` (clamped to [0,1]): startDeg at 0, endDeg at 1, -// linear between, returned normalized to [0, 360). Pure. +// Needle angle for normalized `value` (clamped to [0,1]): startDeg at 0, endDeg at 1, +// linear between, normalized to [0, 360). double knobValueAngleDeg(const KnobArc& arc, double value); -// The needle endpoint for normalized `value`: the point on the knob circle at the value's -// angle, from the center. The shell draws the needle from (centerX, centerY) to this point -// (or lerps toward the center for a shorter needle). Pure. +// Needle endpoint for normalized `value`: the point on the knob circle at the value's +// angle, from the center. KnobPoint knobNeedlePoint(const KnobGeometry& knob, const KnobArc& arc, double value); -// Map a vertical drag onto a knob value: `startValue` is the value at drag start (clamped), -// `dyPixels` the pointer's y displacement in screen coordinates (down = positive). Dragging -// UP increases, DOWN decreases; `dragRangePixels` pixels of travel covers the full 0..1 -// range. Result clamps to [0,1]; a non-positive drag range yields the clamped start value. -// Pure — the inverse map for the knob's drag interaction. +// Maps a vertical drag onto a knob value: `startValue` is the value at drag start, +// `dyPixels` the pointer's y displacement (down = positive). Up increases, down +// decreases; `dragRangePixels` pixels of travel covers the full 0..1 range. double knobDragValue(double startValue, int dyPixels, int dragRangePixels = kKnobDragRangePixels); -// The control a point lands on, given the laid-out `rows`. Returns the control id (ControlDesc -// id) whose interactive area (a Slider's track, a Toggle's whole control area, a Knob's -// circle) contains the point, or -1 for a miss (a gap, the label column, or outside every -// row). The FIRST matching row wins (rows never overlap, so at most one matches). Pure — the -// shell's routing entry point: on a hit it reads the value (valueAtPoint / -// toggleSegmentHitTest / knobDragValue over the ensuing drag) and commits. +// Control a point lands on, given laid-out `rows`. Returns the control id whose +// interactive area contains the point, or -1 for a miss. First matching row wins (rows +// never overlap). int controlAtPoint(const std::vector& rows, int x, int y); } // namespace reasampler::instrument::ui diff --git a/src/core/instrument/ui/waveform_view.cpp b/src/core/instrument/ui/waveform_view.cpp index fc004b9..324a1a6 100644 --- a/src/core/instrument/ui/waveform_view.cpp +++ b/src/core/instrument/ui/waveform_view.cpp @@ -21,8 +21,7 @@ int frameToX(const Rect& area, std::int64_t frameCount, std::int64_t frame) { const int w = std::max(0, area.width); if (frameCount <= 0 || w <= 0) return area.x; const std::int64_t f = clampFrame(frame, frameCount); - // Linear map: x = left + round(f * w / frameCount). Rounding keeps the marker line - // visually centered on its frame; the divide is exact rational (multiply first). + // x = left + round(f * w / frameCount); multiply before divide to keep this exact. const std::int64_t num = f * static_cast(w) + frameCount / 2; return area.x + static_cast(num / frameCount); } @@ -33,8 +32,7 @@ std::int64_t xToFrame(const Rect& area, std::int64_t frameCount, int x) { if (x <= area.x) return 0; if (x >= area.right()) return frameCount; const std::int64_t dx = static_cast(x - area.x); - // Inverse of frameToX: frame = round(dx * frameCount / w). Round so click and marker draw - // agree at bin granularity. + // Inverse of frameToX: frame = round(dx * frameCount / w). const std::int64_t num = dx * frameCount + static_cast(w) / 2; return clampFrame(num / static_cast(w), frameCount); } diff --git a/src/core/instrument/ui/waveform_view.h b/src/core/instrument/ui/waveform_view.h index 8b552cf..3dc58a9 100644 --- a/src/core/instrument/ui/waveform_view.h +++ b/src/core/instrument/ui/waveform_view.h @@ -1,84 +1,52 @@ -// waveform_view.h — PURE waveform/marker geometry + zero-crossing snap for the S11 -// waveform surface. NO VST3, NO REAPER, NO SWELL/LICE types at the boundary. The mirror -// of keyboard_strip / editor_geometry: the fiddly frame<->pixel + marker hit-test + snap -// arithmetic lives here, unit-tested outside the DAW, while the editor shell -// (reasampler_editor.cpp) draws the envelope + markers and marshals mouse events into it. +// waveform_view.h — waveform/marker geometry + zero-crossing snap. Mirror of keyboard_strip/ +// editor_geometry: frame<->pixel + marker hit-test + snap arithmetic lives here, unit-tested +// outside the DAW; the shell draws and marshals mouse events into it. // // The surface maps a sample's full frame span [0, frameCount] linearly across a horizontal -// waveform rect. Draggable MARKERS mark frames of interest (S11: start point, loop start, -// loop end). The marker set is GENERIC — N named markers with drag + snap — deliberately -// not three hardcoded specials, so S15 (Trigger/Gate) can repurpose this same surface with a -// different marker set (start + %-length end + fades) without reworking the machinery. -// -// Interaction resolves through the pure DRAG-DELTA resolver here: the shell captures a grab -// on WM_LBUTTONDOWN (markerAtPoint identifies the grabbed marker), feeds each WM_MOUSEMOVE's -// pixel delta back through resolveDragFrame (which clamps + optionally zero-crossing-snaps), -// and commits on WM_LBUTTONUP. Live feedback is the shell re-drawing the in-flight frame. -// -// It reuses the same Rect + contains() as editor_geometry (one shared geometry idiom), so -// this header depends on editor_geometry.h rather than redefining a rectangle type. Audio -// is the peaks AudioSample float alias (the one house precedent — sampler_core / wav_codec do -// the same), so the zero-crossing helper takes the same mono PCM the shell already decoded. +// waveform rect. Markers are a generic N-named-marker set (not hardcoded specials), so a +// different mode (e.g. start + %-length end + fades) can repurpose the same machinery. #pragma once #include -#include "core/instrument/ui/editor_geometry.h" // Rect, contains — one shared geometry idiom -#include "core/audio/peaks.h" // AudioSample (float), the mono PCM the snap scans +#include "core/instrument/ui/editor_geometry.h" // Rect, contains +#include "core/audio/peaks.h" // AudioSample (float) namespace reasampler::instrument::ui { using audio::AudioSample; -// The width (px) of a marker's grab region either side of its x line: a grab within this many -// pixels of a marker's drawn x is a grab OF that marker. Mirrors keyboard_strip's edge-grab -// idiom — wide enough to grab a 1px line comfortably, narrow enough that adjacent markers stay -// distinguishable. +// Pixel width of a marker's grab region either side of its x line. Mirrors keyboard_strip's +// edge-grab idiom. inline constexpr int kMarkerGrabWidth = 5; -// The x pixel (inside `area`) of frame `frame` under the linear map: frame 0 -> area.x, -// frame frameCount -> area.right(). A frame is clamped to [0, frameCount] before mapping, so an -// out-of-range frame pins to an edge rather than escaping the rect. frameCount <= 0 or a -// zero-width area pins every frame to area.x (a degenerate, non-inverting result). Pure. +// x pixel of `frame` under the linear map: frame 0 -> area.x, frame frameCount -> area.right(). +// Frame is clamped to [0, frameCount] before mapping. frameCount <= 0 or a zero-width area pins +// every frame to area.x. int frameToX(const Rect& area, std::int64_t frameCount, std::int64_t frame); -// The frame a point x (inside `area`) maps to under the inverse linear map, clamped to -// [0, frameCount]. A point left of area.x yields 0; right of area.right() yields frameCount. -// frameCount <= 0 or a zero-width area yields 0. Pure — the inverse of frameToX (round-trips -// to the same frame at bin granularity). +// Inverse of frameToX: the frame a point x maps to, clamped to [0, frameCount]. A point left of +// area.x yields 0; right of area.right() yields frameCount. std::int64_t xToFrame(const Rect& area, std::int64_t frameCount, int x); -// Which marker (index into a caller-supplied parallel `frames` array, in draw order) a grab at -// (x, y) lands on, or -1 for a point off every marker (or off the waveform area). A marker is -// grabbed when x is within kMarkerGrabWidth of its drawn x AND y is inside `area`. First marker -// in order wins a tie where two markers overlap within the grab band (deterministic, mirroring -// keyboard_strip's first-match). `frames` is `count` frame indices; a null/empty array or -// count <= 0 yields -1. Pure — a raw pointer at the boundary (no host container), like -// keyboard_strip::zoneBarAtPoint. +// Which marker (index into the caller's parallel `frames` array, in draw order) a grab at +// (x, y) lands on, or -1 for a miss. A marker is grabbed when x is within kMarkerGrabWidth of +// its drawn x and y is inside `area`. First marker in draw order wins an overlapping tie. int markerAtPoint(const Rect& area, std::int64_t frameCount, const std::int64_t* frames, int count, int x, int y); -// Resolve a drag to a new frame. Given the frame the grabbed marker held at grab time -// (`startFrame`) and the horizontal pixel delta since grab (`dxPixels`), returns the frame the -// marker should now hold: startFrame shifted by round(dxPixels * frameCount / areaWidth), -// clamped to [0, frameCount]. A zero-width area or non-positive frameCount pins the result to -// the clamped startFrame (no motion). This is the single arithmetic behind every marker drag; -// the shell applies clamps BETWEEN markers (start <= loopEnd, loopStart <= loopEnd) after this -// per-marker resolve. Pure — rounding is to the nearest frame. Returns the clamped startFrame -// for dxPixels == 0. +// Resolves a drag to a new frame: `startFrame` shifted by round(dxPixels * frameCount / +// areaWidth), clamped to [0, frameCount]. The shell applies between-marker clamps (e.g. +// start <= loopEnd) after this per-marker resolve. std::int64_t resolveDragFrame(const Rect& area, std::int64_t frameCount, std::int64_t startFrame, int dxPixels); -// The nearest zero-crossing frame to `target` in the mono PCM, for the loop/start snap (the -// S2 zero-crossing-aware requirement). A zero crossing is a frame index i (1 <= i < frames) -// where the sign of pcm[i-1] and pcm[i] differ (a sample exactly 0 counts as its own crossing -// — pcm[i] == 0 snaps to i). The search fans out symmetrically from the clamped target and -// returns the closest crossing frame; ties (equidistant crossings on both sides) resolve to -// the LOWER frame (deterministic). When the PCM has NO sign change anywhere (all one sign, or -// fewer than 2 frames), returns the clamped target unchanged (nothing to snap to — the caller -// keeps the raw frame). `target` is clamped to [0, frames) before searching. Pure — scans the -// decoded PCM the shell already holds; no host types, no file I/O. +// Nearest zero-crossing frame to `target` in the mono PCM, for loop/start snap. A crossing is a +// frame i (1 <= i < frames) where pcm[i-1] and pcm[i] differ in sign (pcm[i] == 0 snaps to i). +// Search fans out symmetrically from the clamped target; an equidistant tie resolves to the +// lower frame. No sign change anywhere (or fewer than 2 frames) returns the clamped target +// unchanged. std::int64_t nearestZeroCrossing(const AudioSample* pcm, std::int64_t frames, std::int64_t target); diff --git a/src/core/json/json.cpp b/src/core/json/json.cpp index ad3d6bb..0aadb59 100644 --- a/src/core/json/json.cpp +++ b/src/core/json/json.cpp @@ -1,6 +1,5 @@ -// core/json implementation — see json.h. The bodies are the (previously -// quintuplicated) bank_model / view_mode_model lexical layer, verbatim; any -// behavioral change here changes five persisted-blob parsers at once. +// core/json implementation — see json.h. Any behavioral change here changes +// every persisted-blob parser that shares this lexical layer at once. #include "core/json/json.h" @@ -11,9 +10,7 @@ namespace reasampler::json { -// --------------------------------------------------------------------------- -// emit helpers -// --------------------------------------------------------------------------- +// -- emit helpers ------------------------------------------------------- void writeEscaped(std::string& out, const std::string& s) { out += '"'; @@ -75,9 +72,7 @@ void writeIntArray(std::string& out, const std::vector& v) { out += ']'; } -// --------------------------------------------------------------------------- -// Reader -// --------------------------------------------------------------------------- +// -- Reader --------------------------------------------------------------- void Reader::skipWs() { while (!eof()) { @@ -117,7 +112,6 @@ bool Reader::parseString(std::string& out) { case 'r': out += '\r'; break; case 't': out += '\t'; break; case 'u': { - // Decode a \uXXXX escape to its code point. auto readHex4 = [&](unsigned int& cp) -> bool { if (pos_ + 4 > s_.size()) return false; cp = 0; @@ -149,7 +143,6 @@ bool Reader::parseString(std::string& out) { return false; // unpaired low surrogate — malformed } - // Encode codePoint as UTF-8. if (codePoint <= 0x7F) { out += static_cast(codePoint); } else if (codePoint <= 0x7FF) { diff --git a/src/core/json/json.h b/src/core/json/json.h index 2af28c3..54c292b 100644 --- a/src/core/json/json.h +++ b/src/core/json/json.h @@ -1,22 +1,19 @@ -// core/json — the ONE hand-rolled JSON lexical layer (Q-W1; audit T2-02 / §2 -// "Parser ×4"). Pure: standard library only — NO REAPER, NO SWELL, NO VST3. +// core/json — the ONE hand-rolled JSON lexical layer. Pure: standard library only +// — NO REAPER, NO SWELL, NO VST3. // -// This module owns the lexical half of the house JSON dialect: the escape-aware -// string literal (incl. \uXXXX + surrogate pairs re-encoded as UTF-8), the bare -// scalar tokens, the number parses (strtod/strtoll with full-token + ERANGE -// rejection), key+':' consumption, unknown-value skipping, and the emit side -// (escaping, %.17g / %d / %lld number rendering, the scoped object writer). -// The DOMAIN grammars — which keys exist, what shape each value takes, what is -// rejected at the model boundary — stay in the consumers (bank_model, bank_book, -// view_mode_model, owned_manifest, tail_control). One lexical definition means -// the five decoders can no longer drift on tolerance or escaping. +// Owns the lexical half of the house JSON dialect: escape-aware string literals +// (incl. \uXXXX + surrogate pairs re-encoded as UTF-8), bare scalar tokens, number +// parsing (strtod/strtoll, full-token + ERANGE rejection), key+':' consumption, +// unknown-value skipping, and the emit side (escaping, %.17g/%d/%lld rendering, +// the scoped object writer). Domain grammars — which keys exist, what shape each +// value takes — stay in the consumers (bank_model, bank_book, view_mode_model, +// owned_manifest, tail_control). // -// Byte-compatibility contract (load-bearing): the emit helpers reproduce the -// prior per-module writers EXACTLY — writeEscaped's escape set, %.17g for -// doubles (shortest form that round-trips every IEEE-754 double bit-for-bit), -// plain decimal for ints — so a re-serialized blob is byte-identical to what -// the pre-extraction writers produced. This was a structural dedupe, not a -// format change; persisted .rpp ext-state must not shift by a byte. +// Byte-compatibility contract (load-bearing): the emit helpers reproduce the prior +// per-module writers EXACTLY — writeEscaped's escape set, %.17g for doubles +// (shortest form that round-trips every IEEE-754 double bit-for-bit), plain +// decimal for ints — so a re-serialized blob is byte-identical to what the +// pre-extraction writers produced. Persisted .rpp ext-state must not shift by a byte. #pragma once @@ -26,9 +23,7 @@ namespace reasampler::json { -// --------------------------------------------------------------------------- -// emit helpers (writer side) -// --------------------------------------------------------------------------- +// -- emit helpers (writer side) ---------------------------------------------- // Appends `s` as a quoted JSON string literal: the seven short escapes, \uXXXX // for remaining control chars, everything else verbatim (UTF-8 passes through). @@ -88,9 +83,7 @@ private: bool first_ = true; }; -// --------------------------------------------------------------------------- -// Reader — the lexical cursor (parser side) -// --------------------------------------------------------------------------- +// -- Reader — the lexical cursor (parser side) ------------------------------- // // Every method returns false on malformed input and never reads out of bounds. // Only the subset the house writers emit is supported. The reader borrows the diff --git a/src/core/model/bank_book.cpp b/src/core/model/bank_book.cpp index 4700ae6..17b4ff5 100644 --- a/src/core/model/bank_book.cpp +++ b/src/core/model/bank_book.cpp @@ -5,19 +5,13 @@ // bank_book implementation — the registry RULES half: construction, pool // privileges, bank lifecycle, active bank, sample movement/removal, slot order, -// and the reference queries. The JSON round-trip half (serialize / deserialize — -// Q-W1's golden-literal-pinned byte format) lives in bank_book_json.cpp, compiled -// into the same bank_book target (the slot_map extraction shape: same header, a -// second TU). The one symbol both halves share is the private static -// BankBook::nameKey display-name folding rule (declared in bank_book.h). +// and the reference queries. The JSON round-trip half lives in bank_book_json.cpp, +// compiled into the same target. The one symbol both halves share is the private +// static BankBook::nameKey display-name folding rule (declared in bank_book.h). namespace reasampler { -// SlotMap lives in core/model/slot_map.cpp (extracted Q-W1, T4-05). - -// --------------------------------------------------------------------------- -// BankBook — construction + bank lookup -// --------------------------------------------------------------------------- +// -- construction + bank lookup ---------------------------------------------- BankBook::BankBook() { Bank pool; @@ -59,9 +53,7 @@ const Bank& BankBook::pool() const { return *bank(kPoolBankId); } -// --------------------------------------------------------------------------- -// Ordinal normalization -// --------------------------------------------------------------------------- +// -- Ordinal normalization ---------------------------------------------------- void BankBook::normalizeOrdinals() { // Stable-sort by ordinal with the pool pinned first, then rewrite ordinals to a @@ -75,16 +67,13 @@ void BankBook::normalizeOrdinals() { banks_[i].ordinal = static_cast(i); } -// --------------------------------------------------------------------------- -// Display-name uniqueness (trimmed + case-insensitive, ASCII) -// --------------------------------------------------------------------------- +// -- Display-name uniqueness (trimmed + case-insensitive, ASCII) -------------- // Folds a display name to its uniqueness key: strip leading/trailing ASCII -// whitespace, lower-case ASCII letters. So "Drums", "drums", and " Drums " share one -// key and cannot coexist. ASCII-only by design — the pure core carries no locale -// facility and must not grow one; bank names are short user labels, not full Unicode -// case-folding candidates. Private static member (Q-W5): the one folding rule shared -// with bank_book_json.cpp's parse-time duplicate-display-name coalesce. +// whitespace, lower-case ASCII letters — so "Drums"/"drums"/" Drums " share one +// key. ASCII-only by design — the pure core carries no locale facility; bank names +// are short user labels, not Unicode case-folding candidates. Private static: the +// one folding rule shared with bank_book_json.cpp's parse-time coalesce. std::string BankBook::nameKey(const std::string& s) { std::size_t b = 0, e = s.size(); auto isWs = [](char c) { return c == ' ' || c == '\t' || c == '\n' || c == '\r'; }; @@ -109,9 +98,7 @@ bool BankBook::displayNameTaken(const std::string& name, const std::string& exce return false; } -// --------------------------------------------------------------------------- -// Bank lifecycle -// --------------------------------------------------------------------------- +// -- Bank lifecycle ------------------------------------------------------------ bool BankBook::createBank(const std::string& id, const std::string& displayName) { if (id.empty()) return false; // ids key the registry @@ -208,9 +195,7 @@ bool BankBook::evacuate(const std::string& id) { return true; } -// --------------------------------------------------------------------------- -// Active bank -// --------------------------------------------------------------------------- +// -- Active bank ---------------------------------------------------------------- bool BankBook::setActiveBank(const std::string& id) { if (bank(id) == nullptr) return false; // unknown id never corrupts state @@ -227,9 +212,7 @@ const BankModel& BankBook::activeIndex() const { return bank(activeBankId_)->index; } -// --------------------------------------------------------------------------- -// Sample movement (index-only) -// --------------------------------------------------------------------------- +// -- Sample movement (index-only) ----------------------------------------------- namespace { @@ -276,9 +259,7 @@ TransferResult BankBook::copySample(const std::string& sampleId, return applyDestAdd(to->index, copy, TransferResult::Copied); } -// --------------------------------------------------------------------------- -// Sample removal (index-only) + the last-reference query -// --------------------------------------------------------------------------- +// -- Sample removal (index-only) + the last-reference query --------------------- RemoveResult BankBook::removeSample(const std::string& sampleId, const std::string& fromBankId, @@ -310,9 +291,7 @@ bool BankBook::updateSampleInPlace(const std::string& sampleId, const Sample& up return false; // no bank holds the id } -// --------------------------------------------------------------------------- -// Sample display order (L7) — SlotMap driven, index membership untouched -// --------------------------------------------------------------------------- +// -- Sample display order — SlotMap driven, index membership untouched ----------- namespace { @@ -323,9 +302,9 @@ std::vector indexIds(const BankModel& idx) { return ids; } -// Squares one bank's SlotMap with its index membership. A map with NO overlap with the -// index (the pre-L7 migration case, or a freshly-constructed bank) is seeded dense from -// insertion order; an existing map is reconciled (drop stale markers, append unmapped). +// Squares one bank's SlotMap with its index membership. A map with NO overlap with +// the index (a bank with no persisted slot data, or freshly constructed) is seeded +// dense from insertion order; an existing map is reconciled (drop stale, append unmapped). void reconcileBankSlots(Bank& b) { const std::vector live = indexIds(b.index); if (b.slots.empty()) { diff --git a/src/core/model/bank_book.h b/src/core/model/bank_book.h index 333d39b..284654a 100644 --- a/src/core/model/bank_book.h +++ b/src/core/model/bank_book.h @@ -1,41 +1,13 @@ #pragma once -// bank_book — the pure core of the multi-bank phase (Phase B), deliberately free -// of any REAPER type so it compiles and unit-tests OUTSIDE the DAW. It is the -// third instance of the same "pure registry + JSON round-trip, unit-tested outside -// the DAW" pattern as bank_model and view_mode_model. +// bank_book — pure multi-bank registry: wraps N BankModel instances (bank_model +// itself is untouched — additive, no bankId on Sample). Movement between banks is +// index-only; files never relocate, banks are logical groupings over one shared pool. // -// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO -// vendor/ includes. Standard library only. -// -// -- What it is -------------------------------------------------------------- -// -// An ordered registry of banks. Each bank = { stable id, display name, ordinal, -// BankModel }. The book WRAPS N BankModel instances — bank_model / BankModel are -// UNTOUCHED (additive: no bankId on Sample). Movement of samples between banks is -// index-only (remove from source's BankModel, add to destination's); files never -// relocate — banks are logical groupings over one shared file pool. -// -// -- The pool (privileged, not special-cased) -------------------------------- -// -// Structurally the pool is bank-zero — one Bank among many, seeded on construction -// with a fixed id (kPoolBankId) and fixed display name (kPoolBankName), ordinal 0. -// Semantically it is privileged, and the privileges are enforced HERE in the pure -// rules layer (CONTEXT.md §Multi-bank guardrail — not deferred to a shell): -// * always exists (seeded on construction; the book never reaches zero banks) -// * un-deletable (deleteBank rejects the pool) -// * un-renamable (renameBank rejects the pool) -// * un-evacuable (evacuate rejects the pool — the pool is evacuation's -// destination, not a source) -// -// -- Id minting is the CALLER'S job (design decision) ------------------------ -// -// createBank takes a caller-supplied stable id, mirroring bank_model's "id -// assigned by the caller" and view_mode_model's mode ids. The pure core has no -// REAPER genGuid / RNG and deliberately introduces none: a fake in-model id source -// would not be a real GUID anyway, and keeping ids caller-supplied lets the B2 -// shell mint a genuine REAPER GUID while the model stays pure and deterministically -// testable. The model still enforces the invariants: non-empty, unique, not the -// reserved pool id. +// The pool is bank-zero (fixed id/name, ordinal 0), privileged and enforced HERE: +// always exists, un-deletable, un-renamable, un-evacuable (evacuate's destination +// only). createBank takes a caller-supplied id — REAPER GUID minting stays in the +// shell so this model stays pure and deterministic; the model still enforces +// non-empty/unique/not-reserved. #include #include @@ -47,26 +19,22 @@ namespace reasampler { -// Q-W1 interim: this god module re-namespaces in its own split wave; until then the -// clean model types it wraps live in reasampler::model. +// Interim: this module re-namespaces later; the model types it wraps live in +// reasampler::model. using namespace model; -// The pool's fixed identity. The id is reserved: createBank rejects it, and the -// pool is always bank-zero. The name is fixed: renameBank rejects the pool. +// The pool's fixed identity: createBank rejects this id; renameBank rejects this name. inline constexpr const char* kPoolBankId = "pool"; inline constexpr const char* kPoolBankName = "Pool"; -// SlotMap — extracted to its own TU/header pair (Q-W1, T4-05): core/model/slot_map.h. -// Included above because Bank carries one per bank. - -// One bank: a stable id, a display name, an ordinal (tab/display order), and its -// own BankModel. The pool is the bank whose id == kPoolBankId. +// One bank: id/display/ordinal/BankModel/SlotMap. The pool is the bank whose +// id == kPoolBankId. struct Bank { std::string id; // stable, persisted; the pool's is kPoolBankId std::string displayName; // mutable for named banks; fixed "Pool" for the pool int ordinal = 0; // display order; pool is 0, named banks 1..N BankModel index; // this bank's samples - SlotMap slots; // L7 display positions of this bank's samples (gap-preserving) + SlotMap slots; // display positions of this bank's samples (gap-preserving) bool isPool() const { return id == kPoolBankId; } @@ -76,16 +44,12 @@ struct Bank { } }; -// Outcome of a cross-bank sample move/copy. Mirrors AddResult's honesty: the op -// reports what happened rather than silently mutating on a bad request. -// - Moved / Copied: the sample was transferred to the destination as a new entry. -// - Collapsed: the destination already held the hash; it collapsed onto the -// existing entry (a no-op add on the destination side). For a -// MOVE the source entry is STILL removed; for a COPY the source -// entry is (as always) retained. -// - RejectedUnknownBank: a source or destination id named no bank. -// - RejectedSampleAbsent: the sample id was not in the source bank. -// - RejectedSameBank: source and destination were the same bank (no-op). +// Outcome of a cross-bank move/copy — reports what happened rather than mutating +// silently on a bad request. +// - Moved / Copied: transferred to the destination as a new entry. +// - Collapsed: destination already held the hash, collapsed onto it (move +// still removes the source; copy keeps it, as always). +// - RejectedUnknownBank / RejectedSampleAbsent / RejectedSameBank: no-op guards. enum class TransferResult { Moved, Copied, @@ -95,21 +59,18 @@ enum class TransferResult { RejectedSameBank, }; -// Scope of a sample-remove (fork R-A, settled 2026-07-24). ThisBank is the default -// and the ONLY behavior surfaced in the UI/action layer; AllBanks is a latent seam — -// live and tested at the model level, promotable later behind this parameter without -// a rewrite, but never wired to an affordance in B5. -// - ThisBank: drop the entry from the one named source bank only. A same-hash entry -// in another bank survives (no cross-bank cascade — dedup is per-bank). -// - AllBanks: drop the sample's entry from EVERY bank that holds the source id -// ("purge from the library"). Latent; unsurfaced. +// Scope of a sample-remove. ThisBank is the only behavior surfaced in the UI; +// AllBanks is a tested latent seam, not wired to any affordance. +// - ThisBank: drop the entry from the one named source bank only (no cross-bank +// cascade — dedup is per-bank). +// - AllBanks: drop the sample's entry from every bank holding it ("purge from +// the library"). enum class RemoveScope { ThisBank, AllBanks, }; -// Outcome of BankBook::removeSample. Mirrors TransferResult's honesty: the op reports -// what happened rather than silently mutating on a bad request. +// Outcome of BankBook::removeSample — same honesty as TransferResult. // - Removed: at least one index entry was dropped. // - RejectedUnknownBank: the source bank id named no bank (ThisBank scope only). // - RejectedSampleAbsent: the sample id was in no bank in scope (nothing removed). @@ -120,8 +81,7 @@ enum class RemoveResult { }; // An ordered registry of banks with the pool seeded as bank-zero, per-bank sample -// indices, an active-bank pointer, and lossless JSON round-trip. The heart of the -// multi-bank phase — mirror of bank_model / view_mode_model. +// indices, an active-bank pointer, and lossless JSON round-trip. class BankBook { public: BankBook(); // seeds the pool (id kPoolBankId, name kPoolBankName, ordinal 0); @@ -129,34 +89,29 @@ public: // -- Bank lifecycle ------------------------------------------------------ - // Creates a named bank with the caller-supplied stable id and display name, - // assigning the next ordinal. Rejects (returns false, no mutation) an empty id, - // a duplicate id, the reserved pool id, or a display name that duplicates an - // existing bank's name (including the pool's "Pool"). Display-name uniqueness is - // trimmed + case-insensitive (ASCII): "Drums", "drums", and " Drums " collide. + // Creates a named bank with a caller-supplied id/display name (next ordinal + // assigned automatically). Rejects (false, no mutation) an empty/duplicate id, + // the reserved pool id, or a duplicate display name (trimmed + case-insensitive, + // ASCII — "Drums"/"drums"/" Drums " collide, including against the pool's "Pool"). bool createBank(const std::string& id, const std::string& displayName); - // Renames a named bank. Rejects (false, no mutation) an unknown id, the pool, or a - // target name already used by a DIFFERENT bank (trimmed + case-insensitive, as - // createBank). Renaming a bank to its own current name is a no-op success. + // Renames a named bank. Rejects (false, no mutation) an unknown id, the pool, or + // a name already used by another bank. Renaming to its own current name is a + // no-op success. bool renameBank(const std::string& id, const std::string& displayName); - // Deletes a NAMED bank, removing it (and its member index entries) from the - // registry. Files are a shell/prune concern and are NOT touched here. Rejects - // (false, no mutation) an unknown id or the pool. Remaining banks' ordinals are - // compacted so the pool stays 0 and named banks stay contiguous 1..N. If the - // deleted bank was active, the active bank falls back to the pool. + // Deletes a named bank and its member entries (files untouched — a shell/prune + // concern). Rejects (false, no mutation) an unknown id or the pool. Remaining + // ordinals compact after; if the deleted bank was active, falls back to the pool. bool deleteBank(const std::string& id); - // Reorders a NAMED bank to `newOrdinal` (clamped into the named-bank range), - // shifting the others to keep ordinals contiguous. The pool is pinned at 0 and - // cannot be reordered. Rejects (false, no mutation) an unknown id or the pool. + // Reorders a named bank to newOrdinal (clamped into range, others shift to stay + // contiguous). The pool is pinned at 0. Rejects an unknown id or the pool. bool reorderBank(const std::string& id, int newOrdinal); - // Moves EVERY member of a named bank into the pool (index-only, observing the - // same destination-collapse-by-hash as a move), leaving the bank empty. Rejects - // (false, no mutation) an unknown id or the pool (the pool is the destination, - // never a source). Returns true on success even if the bank was already empty. + // Moves every member of a named bank into the pool (index-only, same + // destination-collapse-by-hash as a move). Rejects an unknown id or the pool + // (the pool is only ever a destination). Returns true even if already empty. bool evacuate(const std::string& id); // -- Active bank --------------------------------------------------------- @@ -164,123 +119,83 @@ public: // The active bank's id (the capture target). Defaults to the pool. const std::string& activeBankId() const { return activeBankId_; } - // Sets the active bank. Rejects (returns false, no change) an id that names no - // bank — an invalid set never corrupts state. + // Sets the active bank. Rejects (false, no change) an id that names no bank. bool setActiveBank(const std::string& id); - // The active bank's BankModel — the index the capture layer adds to. Always - // valid (the active id always names a live bank; it falls back to the pool). + // The active bank's BankModel — always valid (falls back to the pool). BankModel& activeIndex(); const BankModel& activeIndex() const; // -- Sample movement (index-only; files never relocate) ------------------ - // Moves a sample by id from `fromBankId` to `toBankId`: removes it from the - // source index and adds it to the destination (observing destination - // collapse-by-hash). See TransferResult for the full outcome set. + // Moves a sample by id between banks (destination collapse-by-hash observed). + // See TransferResult for the full outcome set. TransferResult moveSample(const std::string& sampleId, const std::string& fromBankId, const std::string& toBankId); - // Copies a sample by id from `fromBankId` to `toBankId`: the source entry is - // retained, the destination gains it (observing destination collapse-by-hash). - // Same hash may then live in both banks — cross-bank dedup is NOT enforced. + // Copies a sample by id between banks, source retained (destination + // collapse-by-hash observed). Cross-bank dedup is NOT enforced — the same hash + // may then live in both banks. TransferResult copySample(const std::string& sampleId, const std::string& fromBankId, const std::string& toBankId); // -- Sample removal (index-only; the file is NEVER touched — orphaned until prune) -- - // Drops a sample's index entry (the sample-level sibling of move/copy/evacuate). - // Index-only and non-destructive to the file: a last-reference remove leaves the - // file on disk, orphaned until Phase R prune — remove NEVER deletes bytes. - // - // Scope (fork R-A): ThisBank (default, the only surfaced verb) drops the entry from - // `fromBankId` alone; AllBanks (latent seam) drops the sample id from every bank - // that holds it. See RemoveResult for the outcome set. - // * ThisBank: RejectedUnknownBank if `fromBankId` names no bank; RejectedSampleAbsent - // if that bank does not hold the id; Removed on a drop. - // * AllBanks: `fromBankId` is ignored (the id is purged book-wide); - // RejectedSampleAbsent if NO bank held the id; Removed otherwise. - // No mutation occurs on any Rejected outcome (no-op guardrail for the undo layer). + // Drops a sample's index entry — non-destructive to the file (a last-reference + // remove leaves it on disk, orphaned until prune reclaims it). See RemoveScope/ + // RemoveResult for scope and outcome. No mutation on any Rejected outcome. RemoveResult removeSample(const std::string& sampleId, const std::string& fromBankId, RemoveScope scope = RemoveScope::ThisBank); - // -- Sample display order (L7; index membership untouched) --------------- + // -- Sample display order (index membership untouched) ------------------- - // The bank's sample ids in DISPLAY (slot) order — the deterministic order the grid - // iterates, sourced from the bank's SlotMap. Reconciles the map against live index - // membership first (drops stale markers, appends unmapped samples densely), so a - // freshly-migrated or out-of-band-mutated bank always yields a complete order. An - // unknown bank id yields an empty vector. Const-logical but reconciles lazily, so - // it is a non-const member. + // The bank's sample ids in display (slot) order, reconciled against live index + // membership first (drops stale markers, appends unmapped samples densely). An + // unknown bank id yields an empty vector. std::vector orderedSampleIds(const std::string& bankId); - // Ensures every bank's SlotMap is consistent with its index membership: seeds a - // map that has NO overlap with its index from insertion order (the pre-L7 migration - // default — dense, no gaps), and reconciles a partially-populated map (drop stale, - // append unmapped). Idempotent. Called after deserialize and after any capture/ - // transfer that added samples out-of-band of the L7 reorder path. + // Ensures every bank's SlotMap is consistent with its index membership (seeds a + // dense order, or reconciles a partial map). Idempotent — call after deserialize + // or any out-of-band membership change. void reconcileSlots(); - // Reorders sample `id` within `bankId` to `targetSlot` (gap-preserving; see - // SlotMap::reorder). INDEX-ONLY of positions — the sample's membership, file, and - // metadata are untouched (capture != placement holds). Reconciles the bank's slots - // first so the target space is complete. Returns false (no mutation) on an unknown - // bank or an id the bank does not hold. + // Reorders sample `id` within `bankId` to targetSlot (gap-preserving; index/file/ + // metadata untouched). Returns false (no mutation) on an unknown bank or id. bool reorderSample(const std::string& id, const std::string& bankId, int targetSlot); - // Alt-replace (L7 F3): the dragged sample `newId` (already a member of `bankId`) - // takes the slot of the occupant `oldId`, and `oldId` is REMOVED from `bankId`'s - // index (index-only, same semantics as removeSample ThisBank — the file stays on - // disk; owned-manifest/prune govern bytes; hashReferencedElsewhere handles the - // last-reference case). Position of the slot is preserved; only its occupant changes. + // Alt-replace: the dragged `newId` (already a member of bankId) takes the slot of + // `oldId`, and `oldId` is removed from the index (same semantics as removeSample + // ThisBank). Position is preserved; only the occupant changes. // - // POOL GUARD (settled): the index-removal of `oldId` passes the SAME guard the - // remove verb applies — removeSample(oldId, bankId, ThisBank) must return Removed. - // For the pool this is permitted whenever the occupant exists (per-sample removal - // is not a pool privilege violation — the pool's guards are un-delete/rename/evacuate, - // never per-sample remove). If the removal would be rejected (occupant absent), the - // whole replace is rejected: false, NO mutation (neither the index nor the slots - // change), so the shell can fall back to the default insert-shift or a no-op. - // Rejects (false, no mutation) an unknown bank, a `newId`/`oldId` the bank does not - // hold, or `newId == oldId`. NEVER touches disk; introduces no new deletion authority. + // Applies the same pool guard as removeSample — per-sample removal from the pool + // is allowed (the pool's guards are un-delete/rename/evacuate, never per-sample + // remove). Rejects (false, no mutation of either index or slots) an unknown bank, + // a newId/oldId the bank doesn't hold, or newId == oldId. bool replaceSample(const std::string& newId, const std::string& oldId, const std::string& bankId); - // Refreshes a sample IN PLACE wherever it lives in the book (M10 re-capture): - // finds the bank holding `sampleId` and replaces its entry with `updated` - // (order-preserving, no dedup — see BankModel::updateInPlace). Scans banks in - // ordinal order and updates the FIRST holder (a sample id is unique within a - // bank; the same id living in two banks via copy would update the earliest, which - // is acceptable — re-capture operates on the panel's focused single selection). - // Returns false (no mutation) if no bank holds the id or the replacement's path - // is absolute. Index-only and non-destructive to the timeline. + // Refreshes a sample in place wherever it lives (re-capture): finds the bank + // holding sampleId and replaces its entry with `updated` (order-preserving, no + // dedup). Updates the FIRST holder in ordinal order if the id lives in multiple + // banks via copy. Returns false (no mutation) if no bank holds the id or the + // replacement's path is absolute. bool updateSampleInPlace(const std::string& sampleId, const Sample& updated); - // Reference-count query backing the confirm-on-last-reference guardrail: does any - // bank OTHER than `exceptBankId` still hold an entry whose contentHash == `hash`? - // - // Identity is the CONTENT HASH, not the file path: hash is the canonical dedup key - // the whole model already reasons in (findByHash / collapse-by-hash), and two - // entries that share content share one file — so "some other bank still references - // this hash" is exactly "removing here does not orphan the file." An EMPTY hash is - // never matched (it does not participate in dedup, mirroring findByHash), so an - // empty-hash sample reads as referenced-nowhere-else — the safe, confirm-eliciting - // direction (we cannot prove another bank shares an unhashed file). + // Does any bank other than exceptBankId still hold an entry whose contentHash + // == hash? Backs the confirm-on-last-reference guardrail: two entries sharing a + // hash share one file, so this answers "would removing here orphan the file." + // An empty hash never matches (mirrors findByHash) — reads as + // referenced-nowhere-else, the safe confirm-eliciting default. bool hashReferencedElsewhere(const std::string& hash, const std::string& exceptBankId) const; - // Every project-relative file path referenced by ANY bank in the book, pool - // included — the union across the whole book (Phase R, prune). This is the - // safety-critical referenced-set the prune core subtracts: a file referenced by - // any bank (INCLUDING via a copy into a second bank) appears here, so prune never - // reclaims it. Paths are returned VERBATIM (Sample.relativePath, exact strings — - // no normalization), first-seen order across banks in ordinal order then sample - // insertion order, and DE-DUPLICATED (one file referenced by N banks appears - // once). An empty relativePath is skipped (it references no file). Additive - // read-only query; adds no mutation and no coupling to Phase R. + // Every project-relative path referenced by any bank (pool included) — the union + // prune subtracts against. Paths are verbatim (no normalization), first-seen + // order across banks in ordinal then insertion order, de-duplicated. An empty + // relativePath is skipped. std::vector referencedPaths() const; // -- Query --------------------------------------------------------------- @@ -308,33 +223,25 @@ public: // -- Persistence --------------------------------------------------------- - // Serializes the whole book to a JSON string (lossless round-trip): the pool - // folded in as bank-zero + named banks + per-bank indices + ordinals + active - // id. deserialize(serialize(x)) == x. + // Serializes the whole book to JSON (lossless): pool as bank-zero + named banks + // + per-bank indices + ordinals + active id. deserialize(serialize(x)) == x. std::string serialize() const; // Parses a book JSON produced by serialize(). std::nullopt on malformed input. // - // LEGACY MIGRATION: a bare legacy bank_index JSON (the pre-multi-bank shape, an - // object with a "samples" array and no "banks" key) is promoted into the pool's - // index, yielding a book of { pool } with zero named banks — one-way, lossless. - // After migration the book blob is authoritative (the caller persists the book - // shape going forward; the legacy key is retired by the B2 shell). + // A bare legacy bank_index JSON (pre-multi-bank shape: a "samples" array, no + // "banks" key) is promoted into the pool's index — one-way, lossless — yielding + // a book of { pool } with zero named banks. static std::optional deserialize(const std::string& json); - // Resolve a BankBook from the two persisted ext-state values a project may carry: - // the authoritative `banks` blob and the retired-but-possibly-present legacy - // `bank_index` blob. The persist shell (B2) hands both raw strings straight here so - // the load-source decision stays REAPER-free and unit-tested. Precedence: - // 1. non-empty `banksJson` present -> deserialize it (authoritative). If it is - // MALFORMED, do NOT silently fall back to the legacy blob — a corrupt `banks` - // blob is an error, not an absence; return an empty book so a stale legacy key - // can never resurrect a superseded single-bank state over a broken book. - // 2. else non-empty `legacyJson` -> deserialize it (one-way pool migration). - // 3. else (both absent/empty) -> a fresh empty book (pool only). - // Never returns nullopt: an unloadable input degrades to the empty book (matching - // the shell's existing "malformed -> ignore, start empty" behaviour), so the caller - // has one branchless install path. + // Resolves a BankBook from the two persisted ext-state values a project may + // carry: the authoritative `banksJson` and the retired legacy `bank_index` blob. + // 1. non-empty banksJson -> deserialize it. If malformed, do NOT fall back to + // legacy — a corrupt banks blob is an error, not an absence; returns an + // empty book so a stale legacy key can never resurrect superseded state. + // 2. else non-empty legacyJson -> deserialize it (pool migration). + // 3. else -> a fresh empty book (pool only). + // Never returns nullopt — an unloadable input degrades to the empty book. static BankBook loadFromPersisted(const std::string& banksJson, const std::string& legacyJson); @@ -343,41 +250,34 @@ private: std::string activeBankId_; // always names a live bank; defaults to pool // Folds a display name to its uniqueness key: strip leading/trailing ASCII - // whitespace, lower-case ASCII letters. So "Drums", "drums", and " Drums " share - // one key and cannot coexist. ASCII-only by design — the pure core carries no - // locale facility and must not grow one. A private STATIC member (Q-W5, settled) - // because BOTH halves of the split implementation need the ONE folding rule: the - // rules TU (bank_book.cpp, displayNameTaken) and the JSON TU (bank_book_json.cpp, - // deserialize's duplicate-display-name coalesce) — a drifted second copy would let - // a parsed book violate the create/rename uniqueness invariant. + // whitespace, lower-case ASCII letters — so "Drums"/"drums"/" Drums " share one + // key. ASCII-only by design — the pure core carries no locale facility. Private + // static because both halves of the split implementation (rules + JSON) need the + // one folding rule; a drifted second copy would let a parsed book violate the + // create/rename uniqueness invariant. static std::string nameKey(const std::string& s); - // True if a bank OTHER than `exceptId` already carries `name`'s uniqueness key - // (trimmed + case-insensitive, ASCII). Backs the create/rename uniqueness check; - // pass exceptId=id to let a bank keep (or re-case/-space) its own name. + // True if a bank other than exceptId already carries name's uniqueness key. + // Backs the create/rename uniqueness check; pass exceptId=id to let a bank keep + // (or re-case/-space) its own name. bool displayNameTaken(const std::string& name, const std::string& exceptId) const; // Re-sorts banks_ by ordinal (pool pinned first) and rewrites ordinals to a - // contiguous 0..N-1 so the pool is 0 and named banks are 1..N. Called after any - // structural change (create / delete / reorder). + // contiguous 0..N-1. Called after any structural change (create/delete/reorder). void normalizeOrdinals(); // Replaces the book's banks with a parsed set, normalizes ordinals, and resolves // the active bank (falling back to the pool if the id names no bank). Used only - // by deserialize; kept private so the public surface stays create/rename/etc. + // by deserialize. void adoptBanks(std::vector&& banks, const std::string& activeBank); }; // The next bank id to activate when cycling the active bank forward, in ordinal -// order (the ids arrive pool-first, named 1..N, matching banks()). Wraps: the id -// after the last returns the first (pool → named → … → pool). This is the pure -// decision behind the "cycle active bank" action — the shell reads the book's -// ordered bank ids + current active id, asks for the next, and activates it. +// order (pool -> named -> ... -> pool, wraps). Free function (not a member) so it's +// unit-testable against a bare id vector without a full book. // * empty list -> "" (nothing to cycle to) // * single id (pool-only) -> that id (a one-bank book stays put) // * currentBankId not present -> the first id (a sane home to jump to) -// Exposed as a free function (not a BankBook member) so it is unit-testable against -// a bare id vector without a full book. Mirror of view_mode_model's nextModeId. std::string nextBankId(const std::vector& orderedBankIds, const std::string& currentBankId); diff --git a/src/core/model/bank_book_json.cpp b/src/core/model/bank_book_json.cpp index d3b2289..c0a3960 100644 --- a/src/core/model/bank_book_json.cpp +++ b/src/core/model/bank_book_json.cpp @@ -6,33 +6,25 @@ #include "core/json/json.h" -// bank_book JSON round-trip (Q-W5 extraction out of bank_book.cpp — same header, -// compiled into the same bank_book target; the slot_map second-TU shape). The -// registry RULES half stays in bank_book.cpp; the ONE shared symbol is the private -// static BankBook::nameKey folding rule (declared in bank_book.h) — the parse-time -// duplicate-display-name coalesce below must fold names EXACTLY as the create/rename -// uniqueness check does, or a parsed book could violate the in-model invariant. +// bank_book JSON round-trip — a sibling TU to bank_book.cpp, sharing its header +// and target. The registry RULES half stays in bank_book.cpp; the one shared +// symbol is the private static BankBook::nameKey folding rule — the parse-time +// duplicate-display-name coalesce below must fold names EXACTLY as create/rename +// uniqueness does, or a parsed book could violate the in-model invariant. // -// JSON rides on the shared core/json lexical layer (Q-W1), matching bank_model -// and view_mode_model. The book blob nests one bank object per bank, each carrying that -// bank's BankModel serialized by bank_model's OWN writer (BankModel::serialize), -// so per-bank sample serialization stays owned by bank_model and is not duplicated -// here. The book writer emits the bank envelope (id / displayName / ordinal) plus a -// raw "index" member whose value is the BankModel blob verbatim; the parser splits -// the book envelope, then hands each nested index blob straight to -// BankModel::deserialize. Ints use %d; strings are escaped by writeEscaped. -// BYTE-IDENTICAL to the pre-extraction writer — the Q-W1 golden-literal test pins it. +// The book blob nests one bank object per bank, each carrying that bank's +// BankModel serialized by bank_model's OWN writer, so per-bank sample +// serialization stays owned by bank_model and is not duplicated here. The book +// writer emits the bank envelope (id / displayName / ordinal) plus a raw "index" +// member whose value is the BankModel blob verbatim; the parser splits the book +// envelope, then hands each nested index blob straight to BankModel::deserialize. namespace reasampler { -// =========================================================================== -// JSON — writer -// =========================================================================== +// -- JSON — writer ---------------------------------------------------------- namespace { -// Shared core/json emit helpers (Q-W1): the same escape set + %d rendering the -// prior file-local writer carried, so the emitted blob is byte-identical. std::string intToStr(int v) { return json::numToStr(v); } using ObjWriter = json::Writer; @@ -58,8 +50,8 @@ std::string BankBook::serialize() const { // The nested index is bank_model's own JSON, emitted verbatim so the // per-sample shape stays owned by BankModel::serialize (not duplicated). b.keyRaw("index", banks_[i].index.serialize()); - // L7 display positions (gap-preserving). Absent on a pre-L7 blob; the - // parser defaults such a bank's slots from insertion order on load. + // Display positions (gap-preserving). Absent on a pre-existing blob; + // the parser defaults such a bank's slots from insertion order on load. b.keyRaw("slots", banks_[i].slots.serialize()); } out += ']'; @@ -67,13 +59,10 @@ std::string BankBook::serialize() const { return out; } -// =========================================================================== -// JSON — parser (recursive descent; std::nullopt on any malformed input, never UB) -// =========================================================================== +// -- JSON — parser (recursive descent; std::nullopt on malformed input, never UB) -- namespace { -// The book DOMAIN grammar over the shared core/json lexical layer (Q-W1). // parseBank parses one bank object; parseSlots the "slots" array ([{id, slot}, // ...]) into (id, slot) pairs (empty array valid; the pair-level defensive // repair — dupes/conflicts — lives in SlotMap::fromEntries); parseBook the root @@ -112,9 +101,9 @@ bool parseBank(json::Reader& r, Bank& b) { b.index = std::move(*idx); haveIndex = true; } else if (key == "slots") { - // L7 display positions. Absent on a pre-L7 blob (the else-branch skips - // nothing because the key never appears); when present it drives the - // bank's SlotMap. reconcileSlots() (post-adopt) squares it with membership. + // Display positions. Absent on a pre-existing blob; when present it + // drives the bank's SlotMap. reconcileSlots() (post-adopt) squares it + // with membership. std::vector> pairs; if (!parseSlots(r, pairs)) return false; b.slots = SlotMap::fromEntries(pairs); @@ -186,9 +175,7 @@ bool parseBook(json::Reader& r, const std::string& raw, std::vector& banks } else if (key == "activeBank") { if (!r.parseString(activeBank)) return false; } else if (key == "samples") { - // Legacy marker. The legacy index is re-parsed from the whole input below - // (BankModel::deserialize owns that shape); here we only skip the value to - // keep the scan well-formed and note that we saw it. + // Legacy marker; the legacy index is re-parsed from the whole input below. sawSamples = true; if (!r.skipValue()) return false; } else { @@ -253,23 +240,20 @@ std::optional BankBook::deserialize(const std::string& blob) { json::Reader r(blob); if (!parseBook(r, blob, banks, activeBank)) return std::nullopt; - // --- Coalesce duplicate folded display names (B4 re-review fold-in). -------- + // --- Coalesce duplicate folded display names. -------------------------- // The in-model create/rename path enforces unique display names under nameKey, // but a hand-edited .rpp blob can smuggle in two banks whose names fold to the // same key ("Drums" and " drums "). Rejecting the whole book over one collision // would degrade the user's entire library to empty, so instead we AUTO- - // DISAMBIGUATE the later duplicate deterministically: scan in parse order, and - // the first time a folded key repeats, suffix that bank's display name (" 2", - // " 3", …) until its folded key is unique among all names seen so far. The FIRST - // bank to carry a key keeps its name verbatim; only subsequent collisions are - // renamed. No bank or sample is lost, and ids are untouched. The pool is included - // in the seen-set (its "Pool" key is reserved) so a named bank folding to "pool" + // DISAMBIGUATE the later duplicate: scan in parse order, and the first time a + // folded key repeats, suffix that bank's display name (" 2", " 3", …) until its + // folded key is unique among names seen so far — the first bank to carry a key + // keeps its name verbatim. No bank or sample is lost, ids are untouched, and the + // pool's reserved "Pool" key is seeded first so a named bank folding to "pool" // is disambiguated away from it, never the reverse. // - // Hosted HERE (a static member, Q-W5) rather than in the free parseBook because - // it folds through the PRIVATE BankBook::nameKey — the same rule the - // create/rename uniqueness check applies. Runs after parseBook on BOTH shapes; - // the legacy path yields { pool } alone, where the scan is a trivial no-op. + // Hosted here (not in the free parseBook) because it folds through the PRIVATE + // BankBook::nameKey the create/rename uniqueness check also uses. { std::vector seenKeys; seenKeys.reserve(banks.size()); diff --git a/src/core/model/bank_model.cpp b/src/core/model/bank_model.cpp index dee8731..93575f0 100644 --- a/src/core/model/bank_model.cpp +++ b/src/core/model/bank_model.cpp @@ -4,21 +4,14 @@ #include "core/json/json.h" -// bank_model implementation. -// -// JSON rides on the shared core/json lexical layer (Q-W1: one reader/writer, -// no per-module Parser copy). The field set is a flat struct of primitives, -// strings, one enum, a small string array, and a few optionals, so a compact -// writer + recursive-descent DOMAIN parser over json::Reader is the simplest -// thing that works. Doubles are emitted with 17 significant digits (%.17g), the -// shortest form that round-trips every IEEE-754 double exactly, so the -// deserialize(serialize(x)) == x invariant holds bit-for-bit. +// bank_model implementation. JSON rides on the shared core/json lexical layer; +// only the Sample/index DOMAIN grammar lives here. Doubles are 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. namespace reasampler::model { -// --------------------------------------------------------------------------- -// equality -// --------------------------------------------------------------------------- +// -- equality ----------------------------------------------------------- bool SourceRange::operator==(const SourceRange& o) const { return startSeconds == o.startSeconds && endSeconds == o.endSeconds && @@ -51,20 +44,15 @@ bool Sample::operator==(const Sample& o) const { provenance == o.provenance && createdTimestamp == o.createdTimestamp; } -// --------------------------------------------------------------------------- -// path invariant -// --------------------------------------------------------------------------- +// -- path invariant ------------------------------------------------------- -// DECISION: reject absolute paths rather than normalize them. The pure model has -// no knowledge of the project root, so it cannot correctly relativize an absolute -// path — any "normalization" would be a guess that could point at the wrong file. -// Rejecting at the boundary is honest and deterministic; the capture backend (M3) -// is responsible for handing us an already-relative path. Covers POSIX ("/x"), -// Windows drive ("C:\x", "C:/x", "C:foo" drive-relative), and UNC ("\\host\share") -// forms. Any leading : is rejected regardless of the character that follows — -// drive-relative paths ("C:foo.wav") resolve against the drive's current directory, -// not the project root, so they violate the relative-paths-only invariant just as -// much as "C:\foo.wav" does. +// Rejects absolute paths rather than normalizing them: the pure model has no +// knowledge of the project root, so any "normalization" would be a guess that +// could point at the wrong file. Covers POSIX ("/x"), Windows drive ("C:\x", +// "C:/x", "C:foo" drive-relative), and UNC ("\\host\share") forms. Any leading +// : is rejected regardless of what follows — drive-relative paths +// ("C:foo.wav") resolve against the drive's current directory, not the project +// root, so they violate relative-paths-only just as much as "C:\foo.wav" does. static bool isAbsolutePath(const std::string& p) { if (p.empty()) return false; if (p[0] == '/' || p[0] == '\\') return true; // POSIX root or UNC @@ -73,9 +61,7 @@ static bool isAbsolutePath(const std::string& p) { return false; } -// --------------------------------------------------------------------------- -// BankModel -// --------------------------------------------------------------------------- +// -- BankModel ------------------------------------------------------------ AddResult BankModel::add(const Sample& sample) { if (sample.id.empty()) return AddResult::RejectedEmptyId; @@ -139,9 +125,7 @@ std::vector BankModel::byTier(Tier tier) const { return out; } -// --------------------------------------------------------------------------- -// JSON writer -// --------------------------------------------------------------------------- +// -- JSON writer ------------------------------------------------------------ namespace { @@ -182,9 +166,9 @@ void writeSample(std::string& out, const Sample& s) { w.keyBegin("key"); if (s.key) writeEscaped(out, *s.key); else out += "null"; - // Phase S seam fields (D-B). Emitted as null when absent (same shape as `key` - // and `provenance`) so pre-Phase-S JSON — which lacks these keys entirely — - // parses to empty optionals and re-serializes without invention. + // Emitted as null when absent (same shape as `key`/`provenance`) so JSON that + // lacks these keys entirely parses to empty optionals and re-serializes + // without invention. w.keyBegin("rootNote"); if (s.rootNote) out += numToStr(*s.rootNote); else out += "null"; @@ -240,12 +224,9 @@ std::string BankModel::serialize() const { return out; } -// --------------------------------------------------------------------------- -// JSON parser (recursive descent over the shared json::Reader). Returns false -// on any malformed input; never reads out of bounds. Only supports the subset -// our writer emits. The lexical layer (strings, numbers, skip) lives in -// core/json; only the Sample/index DOMAIN grammar lives here. -// --------------------------------------------------------------------------- +// -- JSON parser (recursive descent over the shared json::Reader) ----------- +// Returns false on any malformed input; never reads out of bounds. Only +// supports the subset our writer emits. namespace { diff --git a/src/core/model/bank_model.h b/src/core/model/bank_model.h index c7612bc..c9d391e 100644 --- a/src/core/model/bank_model.h +++ b/src/core/model/bank_model.h @@ -1,11 +1,7 @@ #pragma once -// bank_model — the HEART of ReaSampler, deliberately free of any REAPER type so -// it compiles and unit-tests OUTSIDE the DAW. It owns the per-project sample -// bank: the `Sample` metadata struct and the `BankModel` (add / remove / query / -// tier moves / dedup-by-hash + JSON round-trip to/from std::string). -// -// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO -// vendor/ includes. Standard library only. +// bank_model — the HEART of ReaSampler: the per-project sample bank. `Sample` +// metadata struct + `BankModel` (add/remove/query/tier moves/dedup-by-hash + JSON +// round-trip to/from std::string). #include #include @@ -14,8 +10,8 @@ namespace reasampler::model { -// How the source audio was obtained. Kept in the pure core (no REAPER coupling); -// the capture backends (M3/M8) map their own notion onto these. +// How the source audio was obtained; the capture backends map their own notion +// onto these. enum class SourceMode { MasterMix, // offline render of the master output SelectedTracks, // offline render of selected tracks @@ -31,9 +27,8 @@ enum class Tier { Archive, }; -// Sample-accurate source bounds, in both project seconds and PPQ (ticks). Both -// are stored because capture needs seconds and musical placement needs PPQ; we -// refuse to re-derive one from the other and risk rounding (precision invariant). +// Sample-accurate source bounds, in both project seconds and PPQ (ticks) — both +// stored so capture doesn't re-derive one from the other and risk rounding. struct SourceRange { double startSeconds = 0.0; double endSeconds = 0.0; @@ -44,9 +39,9 @@ struct SourceRange { }; // Present only when a sample was resampled FROM another sample. Carries the -// parent's id and the FX-chain snapshot string (a thin drift fingerprint, NOT a -// restorable chunk) captured at resample time; the re-capture-from-source action -// (M10) uses it to detect chain drift and replay the original capture request. +// parent's id and an FX-chain snapshot (a thin drift fingerprint, NOT a restorable +// chunk) — re-capture-from-source uses it to detect chain drift and replay the +// original capture request. struct Provenance { std::string parentSampleId; std::string fxChainSnapshot; @@ -63,15 +58,13 @@ struct Levels { bool operator==(const Levels& o) const; }; -// Sample-accurate sustain-loop bounds, as frame indices into the captured file -// (Phase S seam field, D-B). A bank intrinsic — a fact about the file, like -// sampleRate or length — consumed by the future MIDI-playback instrument to hold -// notes past the recorded length. Modeled as one optional struct (not two loose -// optionals) so "both points or neither" is a structural invariant, not a rule to -// re-check at every boundary. Frame indices, not seconds, because the loop is a -// per-sample-frame contract; the instrument reads the file's sample rate to relate -// them to time. Invariant (enforced at the deserialize boundary): 0 <= start <= end. -// start == end is a valid zero-length loop marker. +// Sample-accurate sustain-loop bounds, as frame indices into the captured file — a +// bank intrinsic (like sampleRate or length) the MIDI-playback instrument uses to +// hold notes past the recorded length. One optional struct (not two loose +// optionals) so "both points or neither" is structural, not a rule to re-check at +// every boundary. Frame indices, not seconds — the instrument relates them to time +// via the file's sample rate. Invariant (enforced at deserialize): 0 <= start <= +// end; start == end is a valid zero-length loop marker. struct LoopPoints { std::int64_t start = 0; std::int64_t end = 0; @@ -102,23 +95,23 @@ struct Sample { double lengthBeats = 0.0; double captureTempo = 0.0; // project tempo (BPM) at capture time - // Time signature at capture time (L7 F1 — stamped alongside captureTempo so the - // bars.beats.subdivisions read-out is stable under later project meter changes). - // 0/0 means UNSTAMPED (pre-L7 sample, or a capture that could not read the meter); - // the metadata formatter renders a blank musical read-out for 0/0 and keeps s.ms. + // Time signature at capture time, stamped alongside captureTempo so the + // bars.beats.subdivisions read-out is stable under later project meter changes. + // 0/0 means UNSTAMPED (pre-existing sample, or a capture that could not read the + // meter); the metadata formatter renders a blank musical read-out then, keeping s.ms. int captureTimeSigNum = 0; // meter numerator (e.g. 4 in 4/4); 0 = unstamped int captureTimeSigDenom = 0; // meter denominator (e.g. 4 in 4/4); 0 = unstamped std::optional key; // musical key, when known - // Phase S seam fields (D-B) — bank intrinsics for the MIDI-playback instrument, - // additive like `provenance` (M1). Both default cleanly empty: pre-Phase-S - // samples deserialize without them and re-serialize without inventing values. + // Bank intrinsics for the MIDI-playback instrument, additive like `provenance`. + // Both default cleanly empty: pre-existing samples deserialize without them and + // re-serialize without inventing values. // - rootNote: MIDI note (0..127) the sample was recorded at, so the instrument // can repitch it across the keyboard. DISTINCT from the musical `key` above: // `key` is a human label ("F#m"); `rootNote` is the exact pitch for repitch. - // Populated at/after capture only where derivable — left empty (never guessed) - // when the source is not a single played note. + // Populated only where derivable — never guessed when the source isn't a + // single played note. // - loop: sustain-loop bounds, populated only where explicitly set. std::optional rootNote; std::optional loop; @@ -156,7 +149,7 @@ enum class AddResult { // An ordered, id-keyed collection of Samples with content-hash dedup, tier // moves/filtering, and lossless JSON round-trip. Insertion order is preserved -// so a future panel (M5) can iterate in stable order. +// so a panel can iterate in stable order. class BankModel { public: // Adds a sample. Enforces the relative-paths-only invariant and dedups by @@ -168,16 +161,14 @@ public: bool remove(const std::string& id); // Replaces the sample carrying `id` IN PLACE (preserving its position in - // insertion order), with `updated`. Used by M10 re-capture-from-source: a - // provenanced sample's file is regenerated and its metadata (relativePath, - // contentHash, levels, timestamp, ...) refreshed while its identity (id) and - // slot are kept, so the bank panel shows the same tile updated rather than a - // reordered new entry. `updated.id` should equal `id` (the caller keeps the id - // stable); a differing id is written through as given (the caller's contract). - // Does NOT dedup — an in-place refresh of one entry is not a new insert, so the - // collapse-by-hash rule (which guards NEW inserts) does not apply. Returns false - // (no mutation) if `id` is absent or `updated.relativePath` is absolute - // (the relative-paths-only invariant still holds for the replacement). + // insertion order) with `updated`. Used by re-capture-from-source: a + // provenanced sample's file is regenerated and its metadata refreshed while + // its identity (id) and slot are kept, so the panel shows the same tile + // updated rather than a reordered new entry. `updated.id` should equal `id`; + // a differing id is written through as given. Does NOT dedup — an in-place + // refresh is not a new insert, so collapse-by-hash (which guards inserts) + // does not apply. Returns false (no mutation) if `id` is absent or + // `updated.relativePath` is absolute (relative-paths-only still holds here). bool updateInPlace(const std::string& id, const Sample& updated); // Returns the sample with `id`, or nullptr if absent. The pointer is diff --git a/src/core/model/owned_manifest.cpp b/src/core/model/owned_manifest.cpp index e2ae900..6ee00ac 100644 --- a/src/core/model/owned_manifest.cpp +++ b/src/core/model/owned_manifest.cpp @@ -4,27 +4,21 @@ #include "core/json/json.h" -// owned_manifest implementation. -// -// JSON rides on the shared core/json lexical layer (Q-W1, mirror of bank_model / -// bank_book / tail_control). The shape is a single object with one string array: +// owned_manifest implementation. JSON shape is a single object with one string +// array: // // {"owned":["reasampler_bank/a.wav","reasampler_bank/b.wav"]} -// -// so a compact writer + a focused string-array domain parse is all it needs. namespace reasampler::model { -// --------------------------------------------------------------------------- -// path invariant (mirror of bank_model's isAbsolutePath) -// --------------------------------------------------------------------------- +// -- path invariant (mirror of bank_model's isAbsolutePath) ---------------- namespace { // Any leading '/' or '\' (POSIX root / UNC), or a leading : (Windows drive, -// incl. drive-relative "C:foo") is absolute. Same rejection bank_model applies to -// Sample.relativePath — the manifest holds the SAME kind of path, so the invariant -// must match exactly (a path the index accepts must be recordable, and vice versa). +// incl. drive-relative "C:foo") is absolute — same rejection bank_model applies to +// Sample.relativePath; the manifest holds the same kind of path, so the invariant +// must match exactly. bool isAbsolutePath(const std::string& p) { if (p.empty()) return false; if (p[0] == '/' || p[0] == '\\') return true; @@ -35,9 +29,7 @@ bool isAbsolutePath(const std::string& p) { } // namespace -// --------------------------------------------------------------------------- -// mutation / query -// --------------------------------------------------------------------------- +// -- mutation / query ------------------------------------------------------- ManifestAddResult OwnedFileManifest::add(const std::string& relativePath) { if (relativePath.empty()) return ManifestAddResult::RejectedEmptyPath; @@ -53,9 +45,7 @@ bool OwnedFileManifest::contains(const std::string& relativePath) const { return false; } -// --------------------------------------------------------------------------- -// JSON writer (shared core/json escape — byte-identical to the prior local one) -// --------------------------------------------------------------------------- +// -- JSON writer -------------------------------------------------------- std::string OwnedFileManifest::serialize() const { std::string out = "{\"owned\":["; @@ -67,11 +57,8 @@ std::string OwnedFileManifest::serialize() const { return out; } -// --------------------------------------------------------------------------- -// JSON parser (string-array-only DOMAIN grammar over the shared core/json -// lexical layer). Tolerates unknown keys (forward-compat) and requires the -// "owned" value to be an array of strings. -// --------------------------------------------------------------------------- +// JSON parser: string-array-only grammar. Tolerates unknown keys and requires +// the "owned" value to be an array of strings. namespace { diff --git a/src/core/model/owned_manifest.h b/src/core/model/owned_manifest.h index b1bf611..980272e 100644 --- a/src/core/model/owned_manifest.h +++ b/src/core/model/owned_manifest.h @@ -1,31 +1,16 @@ #pragma once -// owned_manifest — the pure core of the owned-file manifest seam (Phase B, B-cap). +// owned_manifest — the set of files the bank system ITSELF created; every file the +// capture path writes gets recorded here so prune can tell the system's own orphans +// (owned ∩ present − referenced) apart from hand-dropped files. Writes and persists +// the manifest only — no prune logic lives here. // -// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO -// vendor/ includes. Standard library only. Unit-tested outside the DAW — the same -// "small pure type + JSON round-trip" pattern as wav_codec / tab_strip. +// NOT a mirror of the bank index: removing/moving an index entry does NOT remove +// the file's manifest record (the manifest tracks files *created*; prune reconciles +// manifest-vs-index later). Only the capture add-path adds to it — no remove verb. // -// -- What it is -------------------------------------------------------------- -// -// The set of files the bank system ITSELF created — every file the capture path -// writes gets recorded here. Phase R prune consumes it to tell the system's own -// orphans (owned ∩ present − referenced) apart from hand-dropped files. B-cap only -// WRITES and PERSISTS the manifest; no prune logic lives here (fork R-D, settled -// 2026-07-24: "defer the feature, design the seam"). -// -// -- What it is NOT ---------------------------------------------------------- -// -// It is NOT a mirror of the bank index. Removing or moving an index entry does NOT -// remove the file's manifest record: the manifest tracks files *created*, and prune -// (Phase R) reconciles manifest-vs-index later. The ONLY thing that adds to it is -// the capture add-path. There is deliberately no remove verb here. -// -// -- The relative-paths-only invariant --------------------------------------- -// -// A manifest path is ALWAYS project-relative (same invariant as Sample.relativePath -// and the persisted BankModel). add() rejects an absolute path rather than guess a -// relativization — the pure model has no project root, so a "normalization" would be -// a guess that could point at the wrong file (mirror of BankModel::add's rejection). +// Paths are ALWAYS project-relative (same invariant as Sample.relativePath). add() +// rejects an absolute path rather than guess a relativization — the pure model has +// no project root, so "normalizing" could point at the wrong file. #include #include @@ -58,12 +43,12 @@ public: // capture of an identical request does not double-record. ManifestAddResult add(const std::string& relativePath); - // True iff the exact path string is recorded. Phase R uses this to attribute a - // present file to the bank system. Exact string match — path normalization (if any) - // is the caller's concern, consistent across add and query. + // True iff the exact path string is recorded. Prune uses this to attribute a + // present file to the bank system. Exact string match — path normalization (if + // any) is the caller's concern, consistent across add and query. bool contains(const std::string& relativePath) const; - // The owned paths in insertion order. Phase R unions this with the on-disk file + // The owned paths in insertion order. Prune unions this with the on-disk file // set; here it is the round-trip + query surface. const std::vector& paths() const { return paths_; } diff --git a/src/core/model/provenance.cpp b/src/core/model/provenance.cpp index 329b66a..f762ef5 100644 --- a/src/core/model/provenance.cpp +++ b/src/core/model/provenance.cpp @@ -4,26 +4,16 @@ #include "core/wire/wire.h" -// provenance implementation — pure, self-contained (no third-party lib, mirror of -// bank_model's hand-rolled encoding discipline). +// provenance implementation — pure, self-contained encoding. // -// ENCODING (the fingerprint string): a length-prefixed, field-ordered format so it -// is unambiguous and forge-proof (a value containing the separator cannot shift -// the parse). Grammar: -// -// "rsprov1" -- magic + version tag -// then, in fixed order, each field as ':' -// -// Every field — including numbers — is emitted as its decimal / %.17g text then -// length-prefixed, so the parser never has to guess a field boundary. A trailing -// field is the track-GUID count followed by that many length-prefixed GUIDs, then -// the folded fxChainIdentity. Numbers use the SAME %.17g the bank model uses so a -// double round-trips bit-for-bit. Any deviation (wrong magic, short read, bad -// number) -> parseFingerprint returns nullopt. -// -// The fxChainIdentity fold is itself length-prefixed per entry field, so it is -// injection-proof on its own and can be embedded whole as one more length-prefixed -// field of the fingerprint. +// Fingerprint grammar: magic "rsprov1" + fixed-order length-prefixed fields +// (':'), so a value containing the separator can never shift the +// parse. Numbers render as decimal/%.17g text before prefixing (same %.17g the +// bank model uses, so doubles round-trip bit-for-bit). Trailing fields: the +// track-GUID count + that many GUIDs, then the folded fxChainIdentity — itself +// length-prefixed per entry field, so it nests safely as one more field. Any +// deviation (bad magic, short read, bad number) -> parseFingerprint returns +// nullopt. namespace reasampler::model { @@ -39,10 +29,9 @@ namespace { constexpr const char* kMagic = "rsprov1"; -// The shared core/wire codec (Q-W1, T2-01b) carries the field grammar + the full -// hardening (incl. the fixed fieldInt range check that closes the old strtol -// silent-narrowing TODO). Only the %.17g double rendering stays local — it is -// this writer's convention, shared with the bank model's JSON doubles. +// The shared core/wire codec carries the field grammar + range-checked fieldInt. +// Only the %.17g double rendering stays local — this writer's convention, shared +// with the bank model's JSON doubles. using wire::putField; using Cursor = wire::Cursor; @@ -112,10 +101,9 @@ std::optional parseFingerprint(const std::string& fingerprint) { std::size_t guidCount = 0; if (!c.fieldSizeT(guidCount)) return std::nullopt; - // Q-W0 T2-01a (the sample_usage count-sanity pattern): each GUID field costs at least - // 2 wire bytes ("0:"), so a count past size/2 is provably bogus — reject BEFORE the - // reserve, so a corrupt/crafted persisted fingerprint can never drive reserve(huge) - // into std::length_error / bad_alloc through the shell. + // Each GUID field costs at least 2 wire bytes ("0:"), so a count past size/2 is + // provably bogus — reject BEFORE the reserve, so a corrupt/crafted persisted + // fingerprint can never drive reserve(huge) into std::length_error / bad_alloc. if (guidCount > fingerprint.size() / 2u + 1u) return std::nullopt; r.trackGuids.reserve(guidCount); for (std::size_t i = 0; i < guidCount; ++i) { @@ -139,7 +127,6 @@ std::optional detectParent( std::optional parent; // the single bank sample all sources point at for (const std::string& src : sourceItemFiles) { - // Resolve this source file against the bank by exact normalized path. const std::string* matchedId = nullptr; for (const BankFileRef& ref : bankFiles) { if (!ref.absolutePath.empty() && ref.absolutePath == src) { diff --git a/src/core/model/provenance.h b/src/core/model/provenance.h index 14c4cfb..b6f3f0e 100644 --- a/src/core/model/provenance.h +++ b/src/core/model/provenance.h @@ -1,35 +1,17 @@ #pragma once -// provenance — the REAPER-free core behind Milestone 10 (re-capture from source). +// provenance — the REAPER-free core behind re-capture-from-source. The shell +// gathers raw inputs from REAPER (source media-file names, FX-chain identity, +// capture range/scope/tail) and hands plain strings/values here. Owns: +// * CaptureRecipe — the recorded request + source FX-chain identity, so +// re-capture can re-run the same request and detect drift. +// * fingerprint codec — encodes/decodes a recipe into the single +// Provenance.fxChainSnapshot string (no schema change). +// * fxChainIdentity — folds FX-chain rows into one drift-detection string. +// * detectParent — pure parent-detection: resolved file path only, no +// fuzzy match, no false parentage. // -// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO -// vendor/ includes. Standard library only. The shell (main.cpp / the action families) -// gathers the raw inputs from REAPER — the source item media-file names, the -// source track FX-chain identity (names / GUIDs / enabled flags), the exact -// capture range, scope, tail — and hands plain strings/values here. This module -// owns: -// -// * CaptureRecipe — the recorded capture request PLUS the source FX-chain -// identity at capture time. Everything "re-capture from -// source" needs to re-run the SAME request against the -// source's CURRENT state, and to tell whether the source -// drifted since capture. -// * the ENCODING of a recipe into the single `Provenance.fxChainSnapshot` -// string (M1's field already JSON-round-trips one string, -// so the whole thin fingerprint rides in it — no schema -// change to Sample). -// * fxChainIdentity — folds the shell-gathered FX-chain rows into one identity -// string (the drift-detection component of the fingerprint). -// * detectParent — the pure parent-detection decision: given the resolved -// absolute media-file path(s) of the capture's source item(s) -// and the bank's path->sampleId map, decide whether this -// capture genuinely derives from a bank sample (P1: identity -// by resolved file path only — no fuzzy match, no false -// parentage). -// -// Fork picks (docs/product/provenance.md, settled 2026-07-23): P1 = a THIN -// reproducibility fingerprint (drift-detect + re-run the same request), NOT a -// serialized FX chunk to restore. P2 = bank-only re-capture. So nothing here -// stores a restorable chain, and nothing here reaches into view_mode_model. +// A THIN reproducibility fingerprint (drift-detect + re-run) — NOT a serialized FX +// chunk to restore; nothing here stores a restorable chain. #include #include @@ -111,7 +93,7 @@ std::string buildFingerprint(const CaptureRecipe& recipe); // mis-driving a re-capture. std::optional parseFingerprint(const std::string& fingerprint); -// --- Parent detection (P1: identity by resolved file path) ------------------- +// -- Parent detection: identity by resolved file path only -------------------- // One bank sample as the detector sees it: its stable id and the ABSOLUTE, // normalized path its file resolves to (the shell resolves relativePath against @@ -122,26 +104,20 @@ struct BankFileRef { std::string absolutePath; // normalized (forward-slash, no trailing slash) }; -// Decides whether a capture derives from a bank sample. -// -// RULE (stated for the handoff, honest — no false parentage): a capture derives -// from a bank sample iff EVERY source item whose media file could be resolved -// points at the SAME bank sample's file (by exact normalized absolute path). If -// the source items resolve to files not in the bank, or to MORE THAN ONE distinct -// bank sample (ambiguous parentage), no parent is recorded. An empty source-file -// set (nothing resolvable) yields no parent. +// Decides whether a capture derives from a bank sample. No false parentage: a +// capture derives from a bank sample iff EVERY source item whose media file could +// be resolved points at the SAME bank sample's file (exact normalized absolute +// path). Files not in the bank, or matching MORE THAN ONE distinct bank sample +// (ambiguous), yield no parent. An empty source-file set yields no parent. // // sourceItemFiles : normalized absolute paths of the capture's source items' -// take media files (the shell gathers + normalizes them). A -// file that could not be resolved is simply omitted by the -// shell — it never becomes an empty string here. +// take media files, gathered by the shell. A file that could +// not be resolved is simply omitted — never an empty string. // bankFiles : the active book's samples as BankFileRefs (path -> id). // -// Returns the parent sample id, or nullopt when the capture is not a genuine -// resample-from-sample. Comparison is exact path identity; the caller normalizes -// both sides identically via normalizeSlashes (which lowercases on Windows) so a -// slash/case difference never spuriously matches or misses. On Windows both sides -// are lowercased before they reach here; on macOS/Linux they are case-exact. +// Returns the parent sample id, or nullopt otherwise. Both sides are normalized +// identically via normalizeSlashes (lowercased on Windows) so a slash/case +// difference never spuriously matches or misses. std::optional detectParent( const std::vector& sourceItemFiles, const std::vector& bankFiles); diff --git a/src/core/model/slot_map.cpp b/src/core/model/slot_map.cpp index 2d60da7..dcb86b4 100644 --- a/src/core/model/slot_map.cpp +++ b/src/core/model/slot_map.cpp @@ -4,12 +4,10 @@ #include "core/json/json.h" -// slot_map implementation (extracted from bank_book, Q-W1 T4-05). +// slot_map implementation. // // The invariant: entries_ is kept sorted ascending by slot, one id per slot, one -// slot per id. Every mutator restores it; queries assume it. serialize rides the -// shared core/json emit helpers — the emitted fragment is byte-identical to the -// pre-extraction bank_book writer. +// slot per id. Every mutator restores it; queries assume it. namespace reasampler::model { @@ -128,8 +126,6 @@ SlotMap SlotMap::fromEntries(const std::vector>& pai std::string SlotMap::serialize() const { // Array of {id, slot} objects in ascending slot order (entries_ is kept sorted). - // json::Writer + numToStr are the same emit path the pre-extraction writer used, - // so the fragment is byte-identical. std::string out; out += '['; for (std::size_t i = 0; i < entries_.size(); ++i) { diff --git a/src/core/model/slot_map.h b/src/core/model/slot_map.h index 59f550a..7cbd0e8 100644 --- a/src/core/model/slot_map.h +++ b/src/core/model/slot_map.h @@ -1,21 +1,13 @@ #pragma once -// slot_map — the L7 gap-preserving display-position carrier for ONE bank (F2 settled: -// plain interchangeable slots, NOT M9 fixed/addressable slots). A slot is just a -// display position a sample id occupies; the map is sample id -> slot (>= 0). Gaps -// are first-class: a bank may have a sample at slot 1 with slot 0 empty (an empty -// first row above an occupied second row). At most one id per slot (a slot is never -// double-occupied) and at most one slot per id (an id sits in exactly one place). +// slot_map — the gap-preserving display-position carrier for ONE bank: plain +// interchangeable slots, not fixed/addressable ones. A slot is a display position a +// sample id occupies; the map is sample id -> slot (>= 0). Gaps are first-class (a +// bank may have slot 1 occupied with slot 0 empty). At most one id per slot, at +// most one slot per id. // -// Position lives HERE, not on Sample (CLAUDE.md wrapping discipline): a copy of one -// sample into two banks may sit at different slots, so position is a per-bank display -// concern owned by the bank's membership. bank_model / Sample stay untouched. -// -// Extracted from bank_book (Q-W1, T4-05): a self-contained ordered-slot container -// with its own serialize, distinct from the multi-bank registry that carries it. -// Behavior covered by bank_book_tests (the round-trip + reorder/reconcile suites); -// a dedicated slot_map_tests target is a welcome follow-up, not a Q-W1 requirement. -// -// PURE: standard library + core/json (serialize) only. +// Position lives HERE, not on Sample: a copy of one sample into two banks may sit +// at different slots, so position is a per-bank display concern owned by the +// bank's membership. bank_model / Sample stay untouched. #include #include @@ -50,7 +42,7 @@ public: // keeps its position. Returns true if the id was mapped. bool remove(const std::string& id); - // Moves `id` to `targetSlot`, gap-preserving (F3 reorder semantics): + // Moves `id` to `targetSlot`, gap-preserving: // * target slot EMPTY -> `id` moves there; its old slot is left empty. // * target slot OCCUPIED -> insert-before-and-shift: `id` takes targetSlot and // every occupant at slot >= targetSlot (except `id` itself) shifts up by one, @@ -62,9 +54,9 @@ public: bool reorder(const std::string& id, int targetSlot); // Rebuilds the map densely from `ids` in the given order (slot i = ids[i]), - // dropping any prior state. The migration path: a pre-L7 bank with no persisted - // slot data is seeded from its BankModel insertion order, densely packed (no gaps), - // so it is visually identical on first post-L7 load. Empty/duplicate ids skipped. + // dropping any prior state. The migration path: a bank with no persisted slot + // data is seeded from its BankModel insertion order, densely packed (no gaps), + // so it is visually identical on first load. Empty/duplicate ids skipped. void resetDense(const std::vector& ids); // Drops any mapping whose id is NOT in `liveIds` (a stale marker whose sample left diff --git a/src/core/reclaim/prune_reconcile.h b/src/core/reclaim/prune_reconcile.h index e744827..7229707 100644 --- a/src/core/reclaim/prune_reconcile.h +++ b/src/core/reclaim/prune_reconcile.h @@ -1,45 +1,32 @@ #pragma once -// prune_reconcile — the pure core of Phase R (Reclaim), Wave 1. The safety-critical -// "which files are orphans" decision, computed with NO filesystem I/O and NO REAPER -// types. The mirror of view_mode_model's reconcile(liveGuids), one level DOWN: it -// reconciles FILES ON DISK against REFERENCED FILES (the union across every bank), -// where reconcile reconciled membership entries against live tracks. -// -// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO -// vendor/ includes, NO filesystem calls. Standard library only. Unit-tested outside -// the DAW — this is the safety-critical part (it decides which bytes get deleted in -// R2/R3), so it is hard-tested here before any I/O exists. -// -// -- The one computation ------------------------------------------------------ +// prune_reconcile — the safety-critical "which files are orphans" decision, computed +// with NO filesystem I/O and NO REAPER types. Unit-tested outside the DAW before any +// I/O exists — this decides which bytes get deleted. // +// The one computation: // orphans = (owned ∩ present) − referenced -// -// * present — files enumerated in the resolved current bank folder (R2 shell). +// * present — files enumerated in the resolved current bank folder (shell). // * referenced — every project-relative path referenced by ANY bank in the book, // pool included (union across the whole book — see BankBook:: // referencedPaths). A file referenced by any bank — including via a // COPY into a second bank — is NEVER an orphan (the prune null test). -// * owned — the owned-file manifest: the files the bank system itself created -// (OwnedFileManifest). A present-but-unowned (hand-dropped) file is -// NEVER reclaimed — prune reclaims only the system's own leavings. +// * owned — the owned-file manifest: files the bank system itself created. A +// present-but-unowned (hand-dropped) file is NEVER reclaimed. // -// The three settled guardrails fall straight out of the set algebra: +// The three guardrails fall straight out of the set algebra: // * ∩ present — never proposes deleting a file that is not on disk (an owned- // but-absent manifest entry yields no orphan, no error). -// * ∩ owned — never a hand-dropped file (ownership attribution, fork R-D). +// * ∩ owned — never a hand-dropped file (ownership attribution). // * − referenced — never a file any bank references (union safety, prune null test). // -// -- Path representation: EXACT-STRING match (safety-critical) ----------------- -// -// Every path in the model is a project-relative string compared VERBATIM: Sample. -// relativePath, OwnedFileManifest::contains (p == relativePath), and BankModel all -// use raw std::string equality — no separator normalization, no case-folding, no -// trailing-slash trimming. This core MATCHES that convention exactly: it compares -// the raw strings the shell supplies. Feeding a consistent spelling across the three -// inputs is the R2 shell's contract (it enumerates the folder, unions the book, and -// reads the manifest against the SAME resolved current folder). Diverging from exact -// match here (e.g. case-insensitive compare) would be the unsafe direction — it could -// let one spelling of a referenced file be treated as an orphan under another. +// Path representation: EXACT-STRING match everywhere — Sample.relativePath, +// OwnedFileManifest::contains, BankModel all use raw std::string equality: no +// separator normalization, no case-folding, no trailing-slash trimming. Feeding a +// consistent spelling across the three inputs is the shell's contract (it enumerates +// the folder, unions the book, and reads the manifest against the SAME resolved +// current folder). Diverging from exact match here (e.g. case-insensitive compare) +// would be the unsafe direction — it could let one spelling of a referenced file be +// treated as an orphan under another. #include #include @@ -48,34 +35,28 @@ namespace reasampler::reclaim { -// The dry-run prune result (Phase R, Wave 2 — report only, no deletion). The thin -// prune shell (persist) fills this from pruneOrphans() + a per-file size stat and hands -// it to the report surface; R3 will act on the SAME set behind the confirm guardrail. -// REAPER-free / filesystem-free by design (the shell does the I/O; this is just the -// tallied outcome), so the count/size aggregation is unit-testable outside the DAW. +// The dry-run prune result — report only, no deletion. The shell fills this from +// pruneOrphans() + a per-file size stat; a confirmed delete later acts on the SAME +// set behind the confirm guardrail. Filesystem-free by design (the shell does the +// I/O; this is the tallied outcome), so the aggregation is unit-testable. // -// * count — number of orphan files (== orphans.size(); the AUTHORITATIVE tally, -// exact even when `orphans` below is a truncated display list). -// * totalBytes — sum of the on-disk sizes of the orphan files, in bytes (reclaimable -// space). A file the stat could not size contributes 0 (never negative). -// * orphans — the orphan file list as project-relative index-spelled paths, in -// folder-enumeration order (deterministic). MAY be truncated for a large -// set (the shell's display cap); `count` stays exact regardless, and -// `truncated` says whether the list was clipped. -// * truncated — true iff `orphans` holds fewer than `count` entries (a large set was -// clipped for display); false when the list is complete. +// * count — number of orphan files (authoritative tally, exact even when +// `orphans` below is a truncated display list). +// * totalBytes — sum of the on-disk sizes of the orphan files, in bytes. A file +// the stat could not size contributes 0 (never negative). +// * orphans — the orphan file list, project-relative, in folder-enumeration +// order (deterministic). MAY be truncated for a large set (the +// shell's display cap); `count` stays exact regardless. +// * truncated — true iff `orphans` holds fewer than `count` entries. // * abortedUnreadableUsage — true iff the scan found a present-but-unreadable -// rsusage_* instance-usage record (pS-usage fail-safe): the orphan -// computation was NOT performed (count 0, empty list) and the prune -// must HALT — deleting with degraded protection is the data-loss -// direction. Set by the session's scan shell, never by -// buildPruneReport (which stays a pure tally). -// * offendingUsageKeys — the exact "rsusage_" ext-state key names that -// triggered the abort (non-empty iff abortedUnreadableUsage). Named -// so the action can print them for operator recovery: a corrupt/ -// oversized key whose owning instance no longer exists is never -// automatically rewritten, so the abort would be permanent without -// a way to clear it. The operator can clear each key via ReaScript: +// rsusage_* instance-usage record: the orphan computation was NOT +// performed (count 0, empty list) and the prune must HALT — +// deleting with degraded protection is the data-loss direction. +// Set by the scan shell, never by buildPruneReport (which stays a +// pure tally). +// * offendingUsageKeys — the exact "rsusage_" key names that triggered the +// abort (non-empty iff abortedUnreadableUsage), so the operator +// can clear each key via ReaScript: // reaper.SetProjExtState(0, "reasampler", "", "") struct PruneReport { std::size_t count = 0; @@ -86,20 +67,18 @@ struct PruneReport { std::vector offendingUsageKeys; // non-empty iff abortedUnreadableUsage }; -// The outcome of an actual prune DELETION (Phase R, Wave 3 — R3). The prune shell fills -// this as it deletes the confirmed orphan set, reporting what it ACTUALLY reclaimed (not -// what it intended to) so a locked/vanished file shows up as a skip, never a false claim. -// REAPER-free / filesystem-free by design (the shell does the deletion; this is the -// tallied outcome), so the count/byte aggregation is unit-testable outside the DAW. +// The outcome of an actual prune DELETION. The shell fills this as it deletes the +// confirmed orphan set, reporting what it ACTUALLY reclaimed (not what it intended) +// so a locked/vanished file shows up as a skip, never a false claim. Filesystem-free +// by design, so the count/byte aggregation is unit-testable. // -// * reclaimedCount — number of files actually removed from disk BY THIS CALL (trash or -// unlink). Already-absent files are NOT counted here. -// * reclaimedBytes — sum of the on-disk sizes of the files actually removed, in bytes. -// * skippedCount — files that could not be or were not reclaimed: stale entries that -// dropped out of the fresh-orphan intersection, files that vanished -// between the plan and the delete call (already absent), and real -// delete failures (locked, conversion error). Never an error/crash. -// * usedTrash — true iff the deletions were routed to the OS trash/recycle bin +// * reclaimedCount — files actually removed from disk BY THIS CALL (trash or +// unlink). Already-absent files are NOT counted. +// * reclaimedBytes — sum of the on-disk sizes of the files actually removed. +// * skippedCount — files not reclaimed: stale entries dropped from the +// fresh-orphan intersection, files that vanished between plan +// and delete, and real delete failures. Never an error/crash. +// * usedTrash — true iff deletions were routed to the OS trash/recycle bin // (recoverable); false iff the platform fell back to hard unlink. struct PruneDeletionResult { std::size_t reclaimedCount = 0; @@ -110,11 +89,11 @@ struct PruneDeletionResult { // Computes the prune orphan set: (owned ∩ present) − referenced. // -// Returns the subset of `present` that is BOTH owned AND unreferenced, in the ORDER -// they appear in `present` (deterministic output — mirror of the insertion-order -// determinism the index / manifest keep; the R2 dry-run reports a stable file list). -// Duplicate spellings within `present` are de-duplicated in the result (a folder -// enumeration yields distinct names, but the core does not rely on that). +// Returns the subset of `present` that is BOTH owned AND unreferenced, in the order +// they appear in `present` (deterministic — mirrors the insertion-order determinism +// the index/manifest keep). Duplicate spellings within `present` are de-duplicated +// in the result (a folder enumeration yields distinct names, but the core does not +// rely on that). // // Pure: no I/O, no REAPER, no hidden state. All three inputs are project-relative // path strings, compared by exact std::string equality (see header note). @@ -122,56 +101,56 @@ std::vector pruneOrphans(const std::vector& present, const std::vector& referenced, const std::vector& owned); -// Union two referenced-path sets into one (pS-usage): the bank's own referencedPaths() -// PLUS the paths held by live ReaSampler 9000 instances (sample_usage::usageHeldPaths). +// Unions two referenced-path sets into one: the bank's own referencedPaths() PLUS +// the paths held by live ReaSampler 9000 instances (sample_usage::usageHeldPaths). // Order-preserving (`primary` first, then the `extra` paths not already present), -// exact-string de-dup — the same comparison convention as everything above, so feeding -// the result to pruneOrphans keeps the `− referenced` guardrail byte-exact. A path held -// ONLY by an instance (e.g. its bank entry was deleted while the instance kept its v10 -// ref) is protected exactly like a bank-referenced one. +// exact-string de-dup — the same comparison convention as everything above, so +// feeding the result to pruneOrphans keeps the `− referenced` guardrail byte-exact. +// A path held ONLY by an instance (its bank entry was deleted while the instance +// kept its ref) is protected exactly like a bank-referenced one. // -// Pure: no I/O, no REAPER. Kept here (not in the shells) so the "instance usage makes a -// file un-prunable" property is provable at the prune layer itself. +// Pure: no I/O, no REAPER. Kept here (not in the shells) so "instance usage makes a +// file un-prunable" is provable at the prune layer itself. std::vector mergeReferenced(const std::vector& primary, const std::vector& extra); -// Tallies a dry-run PruneReport from a computed orphan set and a per-path size lookup. -// PURE (no I/O): the shell does the folder stat and passes the sizes in `sizeByPath`; -// this owns the count / byte-sum / display-truncation decision so it is unit-testable. +// Tallies a dry-run PruneReport from a computed orphan set and a per-path size +// lookup. Pure (no I/O): the shell does the folder stat and passes sizes in +// `sizeByPath`; this owns the count/byte-sum/display-truncation decision. // -// * count == orphans.size() (the authoritative tally, exact regardless of the cap). -// * totalBytes == the sum of sizeByPath[o] over EVERY orphan o (not just the displayed -// ones); a path missing from sizeByPath contributes 0 (an orphan whose -// size could not be stat'd — never negative, never dropped from the sum). +// * count == orphans.size() (authoritative tally, exact regardless of the cap). +// * totalBytes == sum of sizeByPath[o] over EVERY orphan o (not just displayed); a +// path missing from sizeByPath contributes 0 (never negative). // * orphans == the first `displayCap` orphans in input order (the deterministic -// folder-enumeration order pruneOrphans preserved); the whole set when -// count <= displayCap. displayCap == 0 means "no display cap" (whole set). -// * truncated == count > orphans.size() (a large set was clipped for display). +// order pruneOrphans preserved); the whole set when count <= +// displayCap. displayCap == 0 means "no display cap". +// * truncated == count > orphans.size(). // -// Kept separate from pruneOrphans so the safety-critical set algebra stays a pure function -// of three sets, while the presentation tally (which the R2 dry-run and R3 confirm both -// need) is its own small, testable step. +// Kept separate from pruneOrphans so the safety-critical set algebra stays a pure +// function of three sets, while the presentation tally is its own testable step. PruneReport buildPruneReport(const std::vector& orphans, const std::unordered_map& sizeByPath, std::size_t displayCap); -// Computes the confirm-time delete plan: the intersection of the set the user was SHOWN -// and confirmed (`confirmed`) with a FRESH pure-core orphan output (`freshOrphans`) taken -// at delete time. Returns exactly `confirmed ∩ freshOrphans`, in the order of `confirmed` -// (deterministic — the same order the confirm listed). +// Computes the confirm-time delete plan: the intersection of the set the user was +// SHOWN and confirmed (`confirmed`) with a FRESH pure-core orphan output +// (`freshOrphans`) taken at delete time. Returns exactly `confirmed ∩ freshOrphans`, +// in the order of `confirmed` (deterministic — the same order the confirm listed). // -// This is the R3 staleness guard, and it protects in BOTH directions so that "what was +// This is the staleness guard, and it protects in BOTH directions so that "what was // shown is what is deleted" holds no matter what changed between confirm and delete: -// * A confirmed path that is NO LONGER a fresh orphan — a file that vanished (gone from -// `present`), or that some bank now references (gone from `− referenced`), or whose -// ownership changed — is DROPPED (a skip, never an error, never a wrongful delete of a -// now-referenced file). Because `freshOrphans` is itself a pure-core output, the plan -// can never contain a referenced or hand-dropped file: the guard survives recompute. -// * A path that became an orphan AFTER the confirm (in `freshOrphans` but not `confirmed`) -// is NOT deleted — it was never shown, so it is never swept without its own confirm. +// * A confirmed path that is NO LONGER a fresh orphan — vanished (gone from +// `present`), now referenced (gone from `− referenced`), or ownership changed — +// is DROPPED (a skip, never a wrongful delete of a now-referenced file). Because +// `freshOrphans` is itself a pure-core output, the plan can never contain a +// referenced or hand-dropped file: the guard survives recompute. +// * A path that became an orphan AFTER the confirm (in `freshOrphans` but not +// `confirmed`) is NOT deleted — it was never shown, so it's never swept without +// its own confirm. // -// PURE: no I/O, no REAPER. Duplicate spellings in `confirmed` are de-duplicated in the -// result (mirrors pruneOrphans; a confirmed set from a real scan holds distinct names). +// Pure: no I/O, no REAPER. Duplicate spellings in `confirmed` are de-duplicated in +// the result (mirrors pruneOrphans; a confirmed set from a real scan holds distinct +// names). std::vector pruneDeletePlan(const std::vector& confirmed, const std::vector& freshOrphans); diff --git a/src/core/ui/action_bar.cpp b/src/core/ui/action_bar.cpp index 36da30b..9f9c17b 100644 --- a/src/core/ui/action_bar.cpp +++ b/src/core/ui/action_bar.cpp @@ -1,4 +1,4 @@ -// action_bar — pure implementation. See action_bar.h. NO REAPER / SWELL / LICE / vendor. +// action_bar — pure implementation. See action_bar.h. #include "core/ui/action_bar.h" @@ -8,7 +8,6 @@ namespace reasampler::ui { namespace { -// The total button count across all clusters (empty clusters contribute nothing). int totalButtons(const std::vector& clusters) { int n = 0; for (const ClusterSpec& c : clusters) @@ -16,21 +15,16 @@ int totalButtons(const std::vector& clusters) { return n; } -// Fills a slot's label rect from its box. The label spans the full button height — a single-row -// short label (L6: keybinding sub-row removed from the face; binding is in the hover tooltip). -// Insets horizontally so text clears the button edge. void fillTextRects(ActionBarSlot& s, const ActionBarSpec& /*spec*/) { - const int hpad = 4; // horizontal text inset inside the button + const int hpad = 4; const int innerX = s.x + hpad; const int innerW = s.width - 2 * hpad; - if (innerW <= 0) return; // too narrow for text; leave label rect empty + if (innerW <= 0) return; s.labelX = innerX; s.labelY = s.y; s.labelW = innerW; s.labelH = s.height; } -// Tiles the first `visible` buttons into slots, cluster by cluster, left to right. This is the -// ONE placement routine; both computeBarSlots and hitTestActionBar drive it so draw and -// hit-test can never drift. `visible` is assumed already clamped to [0, total]. Returns the -// slots in ascending flat-index order. +// The one placement routine; computeBarSlots and hitTestActionBar both drive it so draw and +// hit-test can't drift apart. std::vector tile(const ActionBarRect& bar, const std::vector& clusters, const ActionBarSpec& spec, int visible) { @@ -43,21 +37,20 @@ std::vector tile(const ActionBarRect& bar, if (btnH <= 0) return slots; int cursorX = bar.x + spec.sidePad; - int flatIndex = 0; // running flat action index across all clusters - int placed = 0; // buttons placed so far (stops at `visible`) + int flatIndex = 0; + int placed = 0; bool firstClusterEmitted = false; for (const ClusterSpec& c : clusters) { - if (c.count <= 0) continue; // skip empty clusters (no gap emitted) + if (c.count <= 0) continue; if (placed >= visible) break; - // Gap BEFORE this cluster (except the first non-empty one). if (firstClusterEmitted) cursorX += spec.clusterGap; firstClusterEmitted = true; for (int i = 0; i < c.count; ++i, ++flatIndex) { - if (placed >= visible) return slots; // overflow cut — stop cleanly - if (i > 0) cursorX += spec.buttonGap; // gap between buttons in the cluster + if (placed >= visible) return slots; + if (i > 0) cursorX += spec.buttonGap; ActionBarSlot s; s.index = flatIndex; @@ -76,9 +69,7 @@ std::vector tile(const ActionBarRect& bar, return slots; } -// The rightmost pixel the first `visible` buttons would occupy (bar.x + sidePad based). Used by -// computeBarFit to test whether a candidate visible-count fits within the bar's usable width. -// Mirrors tile()'s advance math exactly (gaps included) so fit and layout agree. +// Mirrors tile()'s advance math so fit and layout agree. int rightEdgeFor(const ActionBarRect& bar, const std::vector& clusters, const ActionBarSpec& spec, int visible) { if (visible <= 0) return bar.x + spec.sidePad; @@ -93,7 +84,7 @@ int rightEdgeFor(const ActionBarRect& bar, const std::vector& clust for (int i = 0; i < c.count; ++i) { if (placed >= visible) return cursorX; if (i > 0) cursorX += spec.buttonGap; - cursorX += spec.buttonWidth; // this button's right edge + cursorX += spec.buttonWidth; ++placed; if (placed >= visible) return cursorX; } @@ -113,8 +104,6 @@ BarFit computeBarFit(const ActionBarRect& bar, const std::vector& c } const int usableRight = bar.x + bar.width - spec.sidePad; - // Largest prefix of buttons whose right edge stays within the usable right bound. Buttons - // never shrink; trailing ones that do not fit are the overflow (dropped whole). int visible = 0; for (int cand = 1; cand <= total; ++cand) { if (rightEdgeFor(bar, clusters, spec, cand) <= usableRight) @@ -138,7 +127,6 @@ std::vector computeBarSlots(const ActionBarRect& bar, int hitTestActionBar(int px, int py, const ActionBarRect& bar, const std::vector& clusters, const ActionBarSpec& spec) { if (bar.height <= 0 || bar.width <= 0) return -1; - // Reject outside the bar band first (half-open bounds match the slots). if (px < bar.x || px >= bar.x + bar.width || py < bar.y || py >= bar.y + bar.height) return -1; @@ -148,7 +136,7 @@ int hitTestActionBar(int px, int py, const ActionBarRect& bar, if (px >= s.x && px < s.x + s.width && py >= s.y && py < s.y + s.height) return s.index; } - return -1; // inter-button/cluster gap or the overflow dead-zone — a clean miss + return -1; } } // namespace reasampler::ui diff --git a/src/core/ui/action_bar.h b/src/core/ui/action_bar.h index bb754c2..bfcf712 100644 --- a/src/core/ui/action_bar.h +++ b/src/core/ui/action_bar.h @@ -1,73 +1,29 @@ #pragma once #include "core/ui/rect.h" -// action_bar — the REAPER-free, LICE-free layout + hit-test math behind the bank_panel's -// TASK-GROUPED toolbars (Phase L, L2 + L4 + L6). L2's dock-panel layout redesign (DS-3: a -// thorough layout, not a re-skin) groups the action-trigger button inventory BY TASK — a compact -// bar of clusters, each button carrying a label sub-rect spanning -// its full height — a single-row short label (L6: the keybinding sub-row was on the button face -// through L5; L6 moves it to the hover tooltip instead). The bar degrades gracefully on a narrow -// panel by dropping WHOLE trailing buttons (never clipping) so the frequent leading cluster -// survives. -// -// L4 re-homes the inventory across TWO toolbars, BOTH driven by this one module: a TOP toolbar -// (Capture + Placement — the two acts the tool exists for) and a BOTTOM toolbar (the Design-View -// verbs, Tagging then Switching). The tiling is cluster-agnostic — it walks the caller's -// ClusterSpec list in order — so the same computeBarSlots / hitTestActionBar serve both bars; -// only the cluster membership and the band rect differ per toolbar. -// -// Why pure (CLAUDE.md §load-bearing split, DS-1 caution): the panel shell owns the SWELL -// window, the L1-kit draws, and the NamedCommandLookup/Main_OnCommand dispatch — all -// DAW-verified. What is NOT DAW-bound — how the clusters tile the bar, where each button and -// its label sub-rect sit, and which button a click hits — lives HERE, unit-tested outside the -// DAW. Mirror of mode_switch / prune_button. -// -// NAME NOTE (brief §name-collision): ButtonRect / ButtonStripRect / ActionButtonRect / -// SegmentRect / CellRect / FooterRect / KitButtonBox are already owned in this namespace, so -// this module's types are ActionBarRect / ActionBarSlot / ActionCluster — grep-checked free -// before minting. They are a distinct concept (a task-grouped multi-cluster bar with text -// sub-rects), so the separate names are correct, not merely non-colliding. -// -// SCOPE: the destructive PRUNE button is NOT in this bar — it stays set-apart in the footer, -// warn-marked, owned by prune_button (L2 keeps prune deliberately away from the frequent -// action cluster). This module lays out only the non-destructive capture/placement/maintenance -// actions. -// -// PURE MODULE: NO REAPER types, NO SWELL, NO LICE, NO vendor/ includes. Standard library only. +// action_bar — layout + hit-test for the bank_panel's task-grouped toolbars: buttons cluster by +// task (Capture/Placement/Maintenance/Tagging/Switching); on a narrow panel, whole trailing +// buttons drop rather than shrink or clip. The destructive Prune button lives separately in +// prune_button, kept out of this cluster on purpose. #include namespace reasampler::ui { -// The task cluster a button belongs to (the L2 "group by task" mandate). The order here is -// NOT itself the bar order — the caller passes ClusterSpecs in the order it wants; this enum -// only names the groups so a slot can carry (and a test/shell can assert) its membership. -// -// L4 split the panel's buttons across TWO toolbars, each an action_bar instance: -// * the TOP toolbar draws Capture + Placement (the two acts the tool exists for); -// * the BOTTOM toolbar draws the Design-View verbs, grouped Tagging then Switching. -// Both toolbars share this ONE pure layout module (the tiling is cluster-agnostic — it walks -// the caller's ClusterSpec list in order), so a cluster value belongs to whichever toolbar -// the shell places it in; nothing here couples a cluster to a specific bar. +// Task cluster a button belongs to. Cluster order is caller-supplied via ClusterSpec, not fixed +// here; a slot just carries which cluster it landed in. enum class ActionCluster { - Capture, // capture item / track / realtime / batch — top toolbar, primary gesture - Placement, // insert at cursor / insert-conform — top toolbar, placing a sample - Maintenance, // re-capture from source / cancel realtime — rarer upkeep actions - Tagging, // tag / untag selected tracks for the active mode — bottom toolbar (L4) - Switching, // activate Arrange / Design, toggle mode, show-both — bottom toolbar (L4) + Capture, + Placement, + Maintenance, + Tagging, + Switching, }; -// The bar the clusters are drawn into, top-left origin (SWELL/LICE convention). (x, y) is the -// top-left corner; width/height are the bar extents. The panel reserves this as a fixed-height -// band (its own judgment where — above the tail footer, below the split body). -using ActionBarRect = Rect; // Q-W1: the shared concrete ui::Rect (core/ui/rect.h), role-aliased +using ActionBarRect = Rect; -// One visible button's placement within the bar, top-left origin. `index` is the button's -// position in the caller's flat action list (the caller supplies actions in cluster order, so -// index also selects the action to fire on a hit). `cluster` is the task group it was laid out -// under (surfaced so a test can assert the grouping is structural, and the shell can tint a -// cluster). `box` is the whole button rect; `labelBox` is the text area inset horizontally so -// text clears the button edge. Only VISIBLE buttons get a slot — a button that does not fit is -// omitted, never returned clipped, so every slot is fully drawable. +// One visible button's placement, top-left origin. `index` is its position in the caller's flat +// action list (cluster order), so index also selects the action to fire on a hit. Only buttons +// that fit get a slot — overflow is dropped whole, never clipped. struct ActionBarSlot { int index = 0; ActionCluster cluster = ActionCluster::Capture; @@ -75,9 +31,7 @@ struct ActionBarSlot { int y = 0; int width = 0; int height = 0; - // Label rect (absolute, top-left origin), inside `box`. The label spans the full button - // height — a single-row short label only (L6: keybinding sub-row removed from the face; - // binding is surfaced in the hover tooltip instead). + // Label sub-rect, full button height, horizontally inset so text clears the edge. int labelX = 0, labelY = 0, labelW = 0, labelH = 0; bool operator==(const ActionBarSlot& o) const { @@ -88,26 +42,14 @@ struct ActionBarSlot { } }; -// One cluster's button count, in the caller's flat action-list order. The caller passes these -// in the left-to-right order it wants them drawn (top toolbar: Capture then Placement; bottom -// toolbar: Tagging then Switching); a cluster with count 0 is skipped (no gap emitted for it). -// The flat action index a slot carries is the running sum across clusters (cluster 0's buttons -// are indices [0, counts[0]), etc.), so the shell's flat action table lines up with the slots -// by index. +// One cluster's button count, in the order the caller wants it drawn. count == 0 skips the +// cluster (no gap emitted). Flat action indices run cluster-by-cluster in this order. struct ClusterSpec { ActionCluster cluster = ActionCluster::Capture; int count = 0; }; -// Layout inputs for the bar, in pixels. Defaults are the bank_panel action-bar metrics; the -// shell passes its own so draw and hit-test share ONE source of truth. -// * buttonWidth — each button's fixed width (buttons never render narrower; overflow drops -// whole trailing buttons instead of shrinking below this). -// * buttonGap — horizontal gap between buttons WITHIN a cluster. -// * clusterGap — horizontal gap between adjacent clusters (wider than buttonGap so the -// task grouping reads visually; the 8px-grid density decision). -// * sidePad — left/right inset from the bar edges to the first/last button. -// * verticalInset — top/bottom gap inside the bar (buttons read as raised, not full-bleed). +// Layout inputs, in pixels; defaults are the bank_panel action-bar metrics. struct ActionBarSpec { int buttonWidth = 108; int buttonGap = 4; @@ -116,35 +58,23 @@ struct ActionBarSpec { int verticalInset = 3; }; -// How many buttons (from the front, cluster by cluster) fit the bar at `spec.buttonWidth`. -// Split from slot tiling so the shell can size an overflow affordance / count without -// re-deriving it. Trailing buttons that do not fit are the overflow (dropped whole). A -// non-positive bar width, or a bar too narrow for even one button, yields 0. Clamps to -// [0, total-button-count]. +// How many buttons (from the front) fit at spec.buttonWidth. Split out so the shell can size an +// overflow affordance without re-deriving it. A bar too narrow for even one button yields 0. struct BarFit { - int visibleCount = 0; // buttons that fit (laid out), counted from the front - int hiddenCount = 0; // total - visibleCount (the overflow, dropped whole) + int visibleCount = 0; + int hiddenCount = 0; }; BarFit computeBarFit(const ActionBarRect& bar, const std::vector& clusters, const ActionBarSpec& spec); -// Lays out the VISIBLE buttons (per computeBarFit) left-to-right in cluster order: buttons -// pack at buttonWidth with buttonGap inside a cluster and clusterGap between clusters, starting -// at bar.x + sidePad. Each slot carries its flat action index, its cluster, its box, and the -// label sub-rect (full-height single row). Empty clusters emit no gap. Returns exactly -// visibleCount slots in ascending index order. A degenerate bar (width/height <= 0), an empty -// cluster list, or a non-positive buttonWidth yields empty. +// Lays out the visible buttons (per computeBarFit) left-to-right in cluster order. std::vector computeBarSlots(const ActionBarRect& bar, const std::vector& clusters, const ActionBarSpec& spec); -// The flat action index the point (px, py) (SWELL/LICE top-left client coords) lands on, or -1 -// for a miss: outside the bar band, in an inter-button / inter-cluster gap, or past the last -// visible button (the narrow-panel overflow dead-zone — a harmless no-op the shell ignores). -// Half-open bounds [x, x+width) x [y, y+height) match computeBarSlots so no pixel is double- -// claimed and the hit maps to the button drawn there. Unlike an equal-tiled strip, the bar has -// real gaps, so a gap point is a clean miss (not the nearest button). +// Flat action index under (px, py), or -1 for a miss (outside the bar, in a gap, or past the +// last visible button). Gaps are real dead-zones here, not resolved to the nearest button. int hitTestActionBar(int px, int py, const ActionBarRect& bar, const std::vector& clusters, const ActionBarSpec& spec); diff --git a/src/core/ui/bank_grid.cpp b/src/core/ui/bank_grid.cpp index 2522527..85c35e8 100644 --- a/src/core/ui/bank_grid.cpp +++ b/src/core/ui/bank_grid.cpp @@ -1,4 +1,4 @@ -// bank_grid — pure implementation. See bank_grid.h. NO REAPER / SWELL / vendor. +// bank_grid — pure implementation. See bank_grid.h. #include "core/ui/bank_grid.h" @@ -9,8 +9,7 @@ namespace reasampler::ui { namespace { -// Builds a sorted, unique ascending index vector for the inclusive range [a, b] -// (order-agnostic in a/b). Both ends assumed already in-range by the caller. +// Sorted, unique ascending index vector for the inclusive range [a, b] (order-agnostic in a/b). std::vector rangeIndices(int a, int b) { if (a > b) std::swap(a, b); std::vector out; @@ -19,8 +18,6 @@ std::vector rangeIndices(int a, int b) { return out; } -// Clamps `index` to a valid cell (single-selection) result: sole member, focus and -// anchor both at index. Used by plain click and plain arrow. Selection singleSelection(int index) { Selection s; s.indices = {index}; @@ -32,11 +29,9 @@ Selection singleSelection(int index) { } // namespace int columnsForWidth(int panelWidth, const GridSpec& spec) { - // Layout: [gap][cell][gap][cell]...[cell][gap]. n cells occupy - // gap + n*(cellWidth + gap). Solve for the largest n that fits panelWidth, - // clamped to at least 1 so a too-narrow panel still shows a (clipped) column. + // Layout: [gap][cell][gap][cell]...[cell][gap]; n cells occupy gap + n*(cellWidth+gap). const int cell = spec.cellWidth + spec.gap; - if (cell <= 0) return 1; // degenerate spec — one column, avoid divide-by-zero + if (cell <= 0) return 1; const int usable = panelWidth - spec.gap; if (usable < spec.cellWidth) return 1; const int cols = usable / cell; @@ -68,15 +63,12 @@ std::vector computeCellRects(int itemCount, int contentHeight(int itemCount, int panelWidth, const GridSpec& spec) { if (itemCount <= 0) return 0; const int cols = columnsForWidth(panelWidth, spec); - // Ceil-divide item count by columns to get the row count (partial last row - // still occupies a full row of height). - const int rows = (itemCount + cols - 1) / cols; + const int rows = (itemCount + cols - 1) / cols; // ceil-divide return spec.gap + rows * (spec.cellHeight + spec.gap); } std::string thumbnailKeyString(const ThumbnailKey& key) { - // Length-prefix the sampleId so a delimiter byte inside an id cannot forge a - // collision with a different (id, width, generation) triple. + // Length-prefix sampleId so a delimiter byte inside it can't forge a collision. std::string s; s.reserve(key.sampleId.size() + 32); s += std::to_string(key.sampleId.size()); @@ -94,7 +86,6 @@ std::string thumbnailKeyString(const ThumbnailKey& key) { int hitTestCell(int px, int py, const std::vector& rects) { for (std::size_t i = 0; i < rects.size(); ++i) { const CellRect& r = rects[i]; - // Half-open bounds so adjacent (gapless) rects never both claim a pixel. if (px >= r.x && px < r.x + r.width && py >= r.y && py < r.y + r.height) return static_cast(i); @@ -110,15 +101,14 @@ Selection applyClick(const Selection& current, int index, bool ctrl, bool shift, int itemCount) { if (itemCount <= 0 || index < 0 || index >= itemCount) return current; - // Shift takes precedence over ctrl (documented): range-select from the anchor. if (shift) { const int anchor = current.anchor >= 0 && current.anchor < itemCount ? current.anchor - : index; // no valid anchor -> seed at the click + : index; Selection s; s.indices = rangeIndices(anchor, index); s.focus = index; - s.anchor = anchor; // anchor unchanged across a shift-range + s.anchor = anchor; return s; } @@ -126,15 +116,14 @@ Selection applyClick(const Selection& current, int index, bool ctrl, bool shift, Selection s = current; auto it = std::lower_bound(s.indices.begin(), s.indices.end(), index); if (it != s.indices.end() && *it == index) - s.indices.erase(it); // toggle OUT + s.indices.erase(it); else - s.indices.insert(it, index); // toggle IN (keeps sorted order) + s.indices.insert(it, index); s.focus = index; - s.anchor = index; // ctrl-click reseeds the range origin + s.anchor = index; return s; } - // Plain click: sole selection. return singleSelection(index); } @@ -143,8 +132,7 @@ Selection navigate(const Selection& current, NavKey key, int cols, int itemCount if (itemCount <= 0) return current; if (cols < 1) cols = 1; - // A fresh panel (no focus): the first key press focuses cell 0 without moving, - // so the user sees the caret appear before it steps. + // Fresh panel: first key press focuses cell 0 without moving. if (current.focus < 0 || current.focus >= itemCount) { if (shift) { Selection s; @@ -160,22 +148,15 @@ Selection navigate(const Selection& current, NavKey key, int cols, int itemCount int to = from; switch (key) { case NavKey::Left: - // Move one; clamp at cell 0 (stay put on the first cell). if (from > 0) to = from - 1; break; case NavKey::Right: - // Move one; clamp at the last cell (stay put on the last cell). if (from < itemCount - 1) to = from + 1; break; case NavKey::Up: - // Move up a row; if that leaves the grid (top row) stay put. if (from - cols >= 0) to = from - cols; break; case NavKey::Down: { - // Move down a row. If the cell directly below exists, go there. If it - // does not (we're above a MISSING partial-last-row cell) but there ARE - // more cells, clamp to the last cell so the partial row is reachable. - // If we're already in the last populated row, stay put. const int below = from + cols; if (below < itemCount) to = below; @@ -189,7 +170,6 @@ Selection navigate(const Selection& current, NavKey key, int cols, int itemCount if (!shift) return singleSelection(to); - // Shift-extend: keep the anchor (seed it at the origin cell on first extend). const int anchor = current.anchor >= 0 && current.anchor < itemCount ? current.anchor : from; @@ -203,23 +183,14 @@ Selection navigate(const Selection& current, NavKey key, int cols, int itemCount 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. + // std::pow isn't constexpr pre-C++20; derive at runtime, cheap since it's once per bin. 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; } diff --git a/src/core/ui/bank_grid.h b/src/core/ui/bank_grid.h index c6845a6..f4bfe04 100644 --- a/src/core/ui/bank_grid.h +++ b/src/core/ui/bank_grid.h @@ -1,14 +1,7 @@ #pragma once #include "core/ui/rect.h" -// bank_grid — the REAPER-free layout math and cache-key logic behind the docked -// bank_panel (M5, Wave A). The panel shell (shell/panel/) owns the SWELL window, -// LICE drawing, and PCM reads; ALL of that is REAPER-bound and DAW-verified. What -// is NOT DAW-bound — how N sample cells tile a panel of a given pixel size, and -// the key that identifies a cached thumbnail — lives here so it is unit-tested -// outside the DAW (CLAUDE.md §load-bearing split). -// -// PURE MODULE: NO REAPER types, NO SWELL, NO vendor/ includes. Standard library -// only. Builds and unit-tests without REAPER. +// bank_grid — layout math, hit-test, selection, and keyboard nav for the docked bank_panel grid, +// plus its thumbnail cache-key. The panel shell owns SWELL/LICE/PCM; this is the DAW-free half. #include #include @@ -17,50 +10,34 @@ namespace reasampler::ui { -// A single cell's pixel rectangle within the panel, top-left origin (SWELL/LICE -// convention). (x, y) is the top-left corner; width/height are the cell extents. -// These are the draw bounds for one sample's thumbnail; the panel draws its -// waveform envelope inside this rect (minus any internal padding it applies). -using CellRect = Rect; // Q-W1: the shared concrete ui::Rect (core/ui/rect.h), role-aliased +// One cell's pixel rect, top-left origin. Draw bounds for one sample's thumbnail. +using CellRect = Rect; -// Fixed inputs that shape the grid. All in pixels. cellWidth/cellHeight are the -// TARGET cell size; the layout fits as many whole columns as the panel width -// allows (>= 1) and wraps to as many rows as N requires. gap is the pixel spacing -// between adjacent cells (and the outer margin), so cells never touch. +// cellWidth/cellHeight are the target cell size; layout fits as many whole columns as the panel +// width allows (>= 1) and wraps rows as needed. gap is the spacing between cells and the margin. struct GridSpec { int cellWidth = 120; int cellHeight = 72; int gap = 8; }; -// Computes the number of columns that fit in a panel of the given pixel width for -// the spec. Always >= 1 (a panel narrower than one cell still shows one column, -// clipped by the window). Pure arithmetic — the panel passes its live client -// width here and to computeCellRects. +// Columns that fit a panel of the given width. Always >= 1 (a too-narrow panel still shows one +// clipped column). int columnsForWidth(int panelWidth, const GridSpec& spec); -// Tiles `itemCount` cells left-to-right, top-to-bottom into a panel of the given -// pixel width, honoring the spec's cell size and gap. Returns exactly itemCount -// rects in item order (rect i is sample i). A partial last row is left-aligned -// and simply shorter — no centering, no stretching. itemCount == 0 -> empty. -// panelWidth is used only to derive the column count; the returned rects may -// extend below any fixed viewport height (the panel scrolls/clips in Wave B). +// Tiles itemCount cells left-to-right, top-to-bottom. Returns exactly itemCount rects in item +// order. A partial last row is left-aligned, not centered or stretched. itemCount == 0 -> empty. std::vector computeCellRects(int itemCount, int panelWidth, const GridSpec& spec); -// The total pixel height the grid occupies for itemCount cells at the given panel -// width and spec (top margin + rows*cellHeight + inter-row gaps + bottom margin). -// 0 when itemCount == 0. The panel uses this to know its full content height -// (scroll extent in Wave B; for Wave A it sizes the empty-vs-populated decision). +// Total pixel height the grid occupies (top margin + rows*cellHeight + inter-row gaps + bottom +// margin); 0 when itemCount == 0. int contentHeight(int itemCount, int panelWidth, const GridSpec& spec); -// Identifies one cached thumbnail. A cached envelope is valid only while the -// sample's identity, the draw width it was computed at, and the bank generation -// it was computed under all match. Width is part of the key because the envelope -// has exactly `width` bins per channel (peaks::computeEnvelope is width-driven); -// a resized panel needs a fresh envelope. Generation lets the panel invalidate -// every entry when the bank changes (capture / project load) without diffing. +// Identifies one cached thumbnail. Valid only while sample identity, the draw width it was +// computed at (the envelope has exactly `width` bins per channel), and bank generation all match; +// generation bump invalidates every cached entry without diffing. struct ThumbnailKey { std::string sampleId; int width = 0; @@ -72,35 +49,22 @@ struct ThumbnailKey { } }; -// A stable string form of the key, suitable as a map key. Deterministic: the same -// key always yields the same string, distinct keys always differ (the sampleId is -// length-prefixed so an id containing the delimiter cannot collide with another). +// Stable string form of the key for use as a map key. sampleId is length-prefixed so a delimiter +// byte inside an id can't forge a collision. std::string thumbnailKeyString(const ThumbnailKey& key); -// --- Interaction (M5 Wave B): hit-test, selection, keyboard nav -------------- -// -// All REAPER-free so the panel's interaction LOGIC is unit-tested outside the DAW, -// exactly as the layout math is. The panel shell (shell/panel/) reads live mouse -// coordinates / key codes / modifier state via SWELL and calls into these; it owns -// no selection arithmetic of its own. +// --- Interaction: hit-test, selection, keyboard nav -------------------------- -// Hit-tests a point (SWELL/LICE top-left client coords) against a cell-rect list. -// Returns the index of the FIRST rect that contains the point, or -1 for a miss -// (a click in the inter-cell gap, the margin, or below the last row). Half-open -// bounds [x, x+width) x [y, y+height) so adjacent rects never both claim a pixel. +// Index of the first rect containing (px, py), or -1 for a miss (gap, margin, below last row). +// Half-open bounds so adjacent rects never both claim a pixel. int hitTestCell(int px, int py, const std::vector& rects); -// The panel's selection state. `indices` is the selected set as a SORTED, unique -// ascending vector (deterministic for tests and for highlight iteration). `focus` -// is the cell the caret sits on — the audition/extend target — or -1 when nothing -// is focused. `anchor` is the fixed end of a shift-range (the cell a range extends -// FROM); -1 when there is no active range origin. An empty selection has focus and -// anchor both -1. +// Panel selection state. `indices` is sorted unique ascending (deterministic for tests and +// highlight order). `focus` is the caret cell (audition/extend target), -1 when none. `anchor` is +// the fixed end a shift-range extends from, -1 when none. Empty selection: focus == anchor == -1. // -// Invariants (upheld by the pure mutators below, asserted in tests): -// * indices is sorted ascending with no duplicates; -// * every index (and focus/anchor when >= 0) is in [0, itemCount); -// * focus, when >= 0, is a member of indices. +// Invariants upheld by the mutators below: indices sorted/unique; every index (and focus/anchor +// when >= 0) is in [0, itemCount); focus, when >= 0, is a member of indices. struct Selection { std::vector indices; int focus = -1; @@ -113,66 +77,42 @@ struct Selection { bool empty() const { return indices.empty(); } }; -// Applies a mouse click on cell `index` to `current`, returning the new selection. -// Modifier semantics (standard multi-select, matching file-manager conventions): -// * plain (no modifier): select ONLY `index`; focus = anchor = index. -// * ctrl: TOGGLE `index` in/out of the set; focus = index. Anchor moves to -// index on add, and to index on remove too (a ctrl-click reseeds the -// range origin at the clicked cell). If the toggle empties the set, -// focus stays at index (the caret) but the set is empty. -// * shift: select the inclusive RANGE from `anchor` to `index` (replacing the -// set); focus = index, anchor unchanged. With no prior anchor (anchor -// == -1) shift behaves like a plain click (anchor seeds at index). -// `index` out of [0, itemCount) or itemCount <= 0 returns `current` unchanged. -// ctrl and shift together: shift takes precedence (range select), matching common -// UI; documented so the panel need not special-case it. +// Applies a click on cell `index` to `current`. Modifier semantics (file-manager convention): +// * plain: select only `index`; focus = anchor = index. +// * ctrl: toggle `index` in/out; focus = index; anchor reseeds to index either way. +// * shift: select the inclusive range [anchor, index]; focus = index, anchor unchanged. +// No prior anchor behaves like a plain click. +// ctrl+shift together: shift wins (range select). index out of range or itemCount <= 0: no-op. Selection applyClick(const Selection& current, int index, bool ctrl, bool shift, int itemCount); -// A directional key for keyboard navigation. REAPER-free (the shell maps VK_* to -// these) so nav math is testable without SWELL. Enter/Space/Esc are NOT here: they -// drive audition, which is a shell concern (no selection math), so the shell reads -// those key codes directly. +// Directional key for nav; Enter/Space/Esc drive audition and are a shell concern, not modelled +// here. enum class NavKey { Left, Right, Up, Down, Home, End }; -// Moves the focus by one step for `key` in a grid of `cols` columns holding -// `itemCount` cells, returning the new selection. `cols` >= 1. -// * Left/Right move by one cell in linear (row-major) order; Up/Down move by -// `cols`. Movement CLAMPS at the grid ends (no wrap): Right on the last cell, -// Left on the first, Up on the top row, Down past the last cell all stay put. -// (Clamp, not wrap: wrap on a partial last row is surprising and error-prone; -// clamp is the predictable choice — flagged as the deliberate decision.) -// * Down from the second-to-last row into a column with no cell in the last row -// clamps to the last cell rather than overshooting past itemCount. -// * Without shift: the moved-to cell becomes the sole selection; focus = anchor -// = newIndex (a plain arrow reseeds the range origin). -// * With shift: focus moves to newIndex and the selection becomes the inclusive -// range from anchor to newIndex (anchor unchanged); a first shift-arrow with no -// anchor seeds the anchor at the ORIGIN cell before moving. -// * Empty selection (focus == -1): the first arrow focuses cell 0 (Home-like), -// so an arrow press on a fresh panel starts navigation predictably. +// Moves focus by one step for `key` in a `cols`-column grid of `itemCount` cells. +// * Left/Right move linearly; Up/Down move by `cols`. Movement CLAMPS at the grid edges (no +// wrap) — deliberate: wrap on a partial last row is surprising. +// * Down from the row above a missing partial-last-row cell clamps to the last cell rather than +// overshooting past itemCount. +// * Without shift: moved-to cell becomes the sole selection (focus = anchor = newIndex). +// * With shift: focus moves to newIndex, selection becomes the inclusive range from anchor +// (seeded at the origin cell on first extend). +// * Empty selection: first arrow focuses cell 0 without moving. // itemCount <= 0 returns `current` unchanged. 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. +// Maps raw linear amplitude to a perceptual display fraction so quiet content stays visible. + +// Below this, amplitude is treated as silence (display fraction 0). Only knob for the 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. +// Maps a signed linear amplitude in [-1, 1] (a raw envelope extreme, e.g. PeakBin::max/min) to a +// signed display fraction in [-1, 1]: magnitude -> dB, clamped to [kDisplayFloorDb, 0] and +// normalized so the floor -> 0 and 0 dB -> 1, then the original sign is re-applied. Exact zero +// stays 0; full-scale (|linear| == 1.0f) returns exactly +-1.0f. float compressAmplitudeForDisplay(float linear); } // namespace reasampler::ui diff --git a/src/core/ui/card_drag.cpp b/src/core/ui/card_drag.cpp index 6f14fd9..e5712db 100644 --- a/src/core/ui/card_drag.cpp +++ b/src/core/ui/card_drag.cpp @@ -1,4 +1,4 @@ -// card_drag — pure implementation. See card_drag.h. NO REAPER / SWELL / LICE / OS / vendor. +// card_drag — pure implementation. See card_drag.h. #include "core/ui/card_drag.h" @@ -6,7 +6,6 @@ namespace reasampler::ui { namespace { -// Half-open point-in-rect (matches drag_out / bank_grid: [x, x+w) x [y, y+h)). bool insideClient(int px, int py, const PanelClientRect& c) { return px >= c.x && px < c.x + c.width && py >= c.y && py < c.y + c.height; @@ -16,25 +15,18 @@ bool insideClient(int px, int py, const PanelClientRect& c) { CardGesture decideCardGesture(int px, int py, const PanelClientRect& client, const DragState& state, const DragModifiers& mods) { - // No drag / empty payload: nothing to do. if (!state.dragging || !state.hasArmedSamples) return CardGesture::None; - // Precedence 1: pointer left the client rect -> OS drag-out (wins first). if (!insideClient(px, py, client)) return CardGesture::OsDragOut; - // Precedence 2: over a tab / the other bank -> move (or copy on Ctrl). if (mods.region == DropRegion::OtherBankOrTab) return mods.ctrl ? CardGesture::Copy : CardGesture::Move; - // Precedence 3: within the same bank's own grid -> reorder / replace. if (mods.region == DropRegion::SameBankGrid) { - // Alt over an OCCUPIED slot replaces; otherwise reorder (empty = place, - // occupied+no-Alt = insert-before-and-shift). if (mods.alt && mods.slotOccupied) return CardGesture::Replace; return CardGesture::Reorder; } - // Dead space inside the client: a drop here is a no-op. return CardGesture::None; } @@ -56,7 +48,7 @@ std::vector computeSlotRects(int maxSlot, int panelWidth, if (maxSlot < 0) return rects; const int cols = columnsForWidth(panelWidth, spec); - const int count = maxSlot + 1; // slots 0..maxSlot inclusive (empties included) + const int count = maxSlot + 1; rects.reserve(static_cast(count)); for (int slot = 0; slot < count; ++slot) { @@ -76,16 +68,13 @@ std::vector computeSlotRects(int maxSlot, int panelWidth, std::vector computeSlotRectsForDrop(int maxSlot, int panelWidth, const GridSpec& spec) { const int cols = columnsForWidth(panelWidth, spec); - // One trailing row of slots past the last occupied slot — the drop-target extension. - // When maxSlot < 0 (empty bank) the trailing row begins at slot 0. const int firstTrailing = maxSlot + 1; - const int newMax = firstTrailing + cols - 1; // fills one full trailing row + const int newMax = firstTrailing + cols - 1; // one full trailing row return computeSlotRects(newMax, panelWidth, spec); } int hitTestSlot(int px, int py, const std::vector& rects) { for (const SlotCellRect& r : rects) { - // Half-open bounds so adjacent rects never both claim a pixel. if (px >= r.x && px < r.x + r.width && py >= r.y && py < r.y + r.height) return r.slot; diff --git a/src/core/ui/card_drag.h b/src/core/ui/card_drag.h index 1e2b379..731206c 100644 --- a/src/core/ui/card_drag.h +++ b/src/core/ui/card_drag.h @@ -1,32 +1,20 @@ #pragma once -// card_drag — the REAPER-free decision logic behind the L7 in-grid reorder drag. Three -// pure concerns live here so they are unit-tested outside the DAW (CLAUDE.md §load-bearing -// split); the SWELL wiring, SetCursor call, cursor resources, and drop-target draw stay in -// the shell (shell/panel/panel_drag.cpp). Mirror of drag_out::decideGesture. +// card_drag — decision logic behind the in-grid reorder drag. Mirror of drag_out::decideGesture; +// SWELL wiring, SetCursor, cursor resources, and drop-target draw stay in the shell. // -// 1. GESTURE PRECEDENCE (F3 settled). A live drag resolves to exactly one gesture, in a -// strict precedence the shell evaluates on every mouse-move / at drop: -// (1) pointer LEFT the client rect -> OsDragOut (hand off to the OS) -// (2) else drop over a tab / the OTHER bank -> Move | Copy (Ctrl = Copy) -// (3) else drop within the SAME bank's grid -> Reorder | Replace -// - empty slot -> Reorder (place there) -// - occupied slot, no modifier -> Reorder (insert-before-and-shift) -// - occupied slot, Alt held -> Replace (Alt-replace-over-occupied) -// So leave-client wins first, then other-bank, then same-bank-grid = reorder/replace. -// This keeps the reorder gesture from ever stealing a bank-move or OS-drag. +// Gesture precedence, evaluated on every mouse-move / at drop, strict order: +// (1) pointer LEFT the client rect -> OsDragOut (hand off to the OS) +// (2) else drop over a tab / the OTHER bank -> Move | Copy (Ctrl = Copy) +// (3) else drop within the SAME bank's grid -> Reorder | Replace +// - empty slot -> Reorder (place there) +// - occupied slot, no modifier -> Reorder (insert-before-and-shift) +// - occupied slot, Alt held -> Replace +// Leave-client wins first, then other-bank, then same-bank-grid — so reorder can never steal a +// bank-move or an OS-drag. // -// 2. SLOT HIT-TEST. Which grid SLOT a pointer sits over, sparse-aware: the grid tiles -// slots 0..maxSlot including empty ones, so hit-testing maps a point to a slot index -// (empty or occupied) or -1 for a miss. The pixel<->slot rect math extends bank_grid's -// dense tiling to the gap-preserving slot layout. -// -// 3. DROP-RESULT -> CURSOR CUE. The resolved gesture maps to a cursor cue enum the shell -// turns into a SetCursor call. The cue DECISION is pure (here); the shell owns only -// the SetCursor call and the cursor resources. The Replace cue appears ONLY when Alt -// is actually held over an occupied slot (precedence rule 3's Alt branch). -// -// PURE MODULE: NO REAPER types, NO SWELL, NO LICE, NO OS, NO vendor/ includes. Standard -// library only. Reuses drag_out's PanelClientRect / DragState and bank_grid's CellRect. +// Also owns: sparse-aware slot hit-test (a point -> grid slot, empty or occupied, extending +// bank_grid's dense tiling), and the gesture -> cursor-cue mapping (Replace's cue appears only +// when Alt is actually held over an occupied slot). #include @@ -35,79 +23,54 @@ namespace reasampler::ui { -// Which drop region the pointer currently sits over WITHIN the client rect. The shell -// classifies the live pointer against its own region geometry (tab strip / other bank -// region / this bank's own grid) and passes the verdict; card_drag does not know panel -// layout, only the precedence over these verdicts. (When the pointer has left the client -// rect the shell need not compute this — OsDragOut wins first regardless.) +// Which drop region the pointer sits over within the client rect; the shell classifies against +// its own region geometry and passes the verdict — card_drag knows only precedence over these. enum class DropRegion { - SameBankGrid, // over the dragged samples' OWN bank grid — a reorder/replace target - OtherBankOrTab, // over a tab or the other region's bank — a move/copy target - DeadSpace, // inside the client but over no drop target (header, footer, gap) + SameBankGrid, // over the dragged samples' OWN bank grid — reorder/replace target + OtherBankOrTab, // over a tab or the other region's bank — move/copy target + DeadSpace, // inside the client but over no drop target }; -// The resolved gesture — one clean outcome the shell acts on and maps to a cursor. +// The resolved gesture the shell acts on and maps to a cursor. enum class CardGesture { - None, // no drag under way, or an empty payload — do nothing - OsDragOut, // pointer left the client rect — hand off to the native OS drag (drag_out) - Move, // drop over another bank/tab, no Ctrl — move the samples there - Copy, // drop over another bank/tab, Ctrl held — copy the samples there - Reorder, // drop within the same bank grid — reorder to the target slot - Replace, // drop within the same bank grid, Alt over an OCCUPIED slot — replace + None, + OsDragOut, + Move, + Copy, + Reorder, + Replace, }; -// The live drag inputs the precedence decision needs beyond position + client rect: -// region — the shell's verdict on what the pointer sits over (see DropRegion). -// targetSlot — the slot the pointer sits over in the same-bank grid, or -1 (used only -// when region == SameBankGrid to decide empty-vs-occupied). -// slotOccupied — whether targetSlot currently holds a sample (drives Reorder vs Replace). -// ctrl — Ctrl held (Copy vs Move over another bank). -// alt — Alt held (Replace vs Reorder over an occupied same-bank slot). +// Live drag inputs the precedence decision needs beyond position + client rect. struct DragModifiers { DropRegion region = DropRegion::DeadSpace; - int targetSlot = -1; - bool slotOccupied = false; - bool ctrl = false; - bool alt = false; + int targetSlot = -1; // slot under the pointer in SameBankGrid; -1 otherwise + bool slotOccupied = false; // drives Reorder vs Replace + bool ctrl = false; // Copy vs Move over another bank + bool alt = false; // Replace vs Reorder over an occupied same-bank slot }; -// Resolves the gesture for a drag at pointer (px, py) over `client`, given the drag -// `state` and the live `mods`. Precedence exactly as documented above. -// * Not dragging / no armed samples: None. -// * Pointer OUTSIDE the client rect: OsDragOut (wins first — invariant #4 boundary). -// * OtherBankOrTab: Copy if ctrl else Move. -// * SameBankGrid: Replace iff (alt AND the target slot is occupied); else Reorder -// (whether the slot is empty — place — or occupied without Alt — insert-shift). -// * DeadSpace inside the client: None (a drop here is a no-op). +// Resolves the gesture for a drag at pointer (px, py) over `client`. See precedence above. CardGesture decideCardGesture(int px, int py, const PanelClientRect& client, const DragState& state, const DragModifiers& mods); -// The cursor cue the shell should show for a resolved gesture. 1:1 with CardGesture but -// named as a cursor concern so the shell maps it to a SetCursor resource. None -> the -// default arrow. The Replace cue is produced ONLY for CardGesture::Replace (which itself -// requires Alt-over-occupied), satisfying "the replace cursor appears only while Alt is -// held over an occupied slot." enum class CursorCue { - Default, // arrow — no drag, or dead space - Reorder, // within-bank reorder - Move, // move to another bank/tab - Copy, // copy to another bank/tab - OsDragOut, // pointer left the client (the OS drag loop owns the cursor once handed off) - Replace, // Alt-replace over an occupied slot + Default, + Reorder, + Move, + Copy, + OsDragOut, + Replace, }; -// Maps a resolved gesture to its cursor cue (pure — the shell owns SetCursor only). CursorCue cursorForGesture(CardGesture g); // --- Sparse-aware slot layout + hit-test -------------------------------------- -// The pixel rect of one grid SLOT (empty or occupied). Distinct from bank_grid's CellRect -// only in intent — a SlotCellRect carries the slot index it draws, so the shell can map a -// drawn/hit rect back to the model slot without a parallel array. width/height match the -// grid spec; (x, y) is the top-left in the region's grid-viewport coordinates (the shell -// translates by the grid origin exactly as regionCellRects does today). +// One grid SLOT's pixel rect (empty or occupied); carries its slot index so the shell can map a +// rect back to the model slot without a parallel array. struct SlotCellRect { - int slot = 0; // the model slot this rect represents (0..maxSlot) + int slot = 0; int x = 0; int y = 0; int width = 0; @@ -119,29 +82,19 @@ struct SlotCellRect { } }; -// Tiles slots 0..maxSlot (INCLUSIVE) into a panel of the given pixel width, honoring the -// grid spec — the sparse-aware sibling of bank_grid::computeCellRects. Every slot in -// [0, maxSlot] gets a rect (empty slots included) so a gap draws as an empty cell and a -// drop targets it precisely. `maxSlot` < 0 -> empty (no occupied slots). The rects use the -// SAME column/row math as computeCellRects (slot index in place of item index), so an -// all-dense map (slots 0..N-1) lays out identically to today's grid. +// Tiles slots [0, maxSlot] inclusive (empty slots included, so a gap draws and a drop targets it +// precisely). maxSlot < 0 -> empty. Same column/row math as bank_grid::computeCellRects. std::vector computeSlotRects(int maxSlot, int panelWidth, const GridSpec& spec); -// Like computeSlotRects but extends one full trailing row of slots beyond maxSlot so a -// drop pointer past the last occupied card still resolves to a valid target slot. The -// trailing slots (maxSlot+1 .. maxSlot+cols) are empty — a drop on any of them calls -// reorderSample with that slot index, which places the card there directly (no shift, -// because the slot is empty). Used ONLY for drop hit-testing; the draw path uses -// computeSlotRects (no trailing ghost row in the visual). -// When maxSlot < 0 the trailing row starts at slot 0 (same as a fresh bank with no cards). +// Like computeSlotRects but extends one full trailing row past maxSlot so a drop pointer beyond +// the last occupied card still resolves to a valid (empty) target slot. Drop hit-testing only — +// the draw path uses computeSlotRects, no ghost row in the visual. std::vector computeSlotRectsForDrop(int maxSlot, int panelWidth, const GridSpec& spec); -// Hit-tests a point against slot rects (half-open bounds, matching hitTestCell). Returns -// the SLOT index (rect.slot) of the first rect containing the point, or -1 on a miss (gap, -// margin, below the last row). NOTE the return is the slot index, NOT the vector index — -// callers reason in model slots. +// Slot index (rect.slot, NOT the vector index) of the first rect containing the point, or -1 on +// a miss. Half-open bounds, matching hitTestCell. int hitTestSlot(int px, int py, const std::vector& rects); } // namespace reasampler::ui diff --git a/src/core/ui/card_meta.cpp b/src/core/ui/card_meta.cpp index 8987dbf..12b0176 100644 --- a/src/core/ui/card_meta.cpp +++ b/src/core/ui/card_meta.cpp @@ -1,4 +1,4 @@ -// card_meta — pure implementation. See card_meta.h. NO REAPER / SWELL / LICE / vendor. +// card_meta — pure implementation. See card_meta.h. #include "core/ui/card_meta.h" @@ -8,33 +8,26 @@ namespace reasampler::ui { std::string formatBarsBeats(const MusicalLength& m) { - // No derivable musical read-out without a positive tempo AND a stamped meter. if (m.tempoBpm <= 0.0 || m.timeSigNum <= 0 || m.timeSigDenom <= 0) return {}; const double len = m.lengthSeconds > 0.0 ? m.lengthSeconds : 0.0; - // Total beats in THIS meter. A quarter-note is 60/tempo s; a beat is (4/denom) - // quarter-notes, so a beat lasts (60/tempo) * (4/denom) seconds. beats = len / that. + // A quarter-note is 60/tempo s; a beat is (4/denom) quarter-notes. const double secondsPerBeat = (60.0 / m.tempoBpm) * (4.0 / m.timeSigDenom); double totalBeats = len / secondsPerBeat; - // Snap to an exact beat when we are within a hundredth-of-a-beat epsilon of one, so a - // bar-aligned capture reads "2.1.00" rather than "1.4.99" from FP error just under the - // boundary. The epsilon is well below the .01 display quantum, so it never mis-rounds a - // genuinely fractional length. + // Snap to an exact beat within epsilon so a bar-aligned capture reads "2.1.00" rather than + // "1.4.99" from FP error just under the boundary. const double snapped = std::floor(totalBeats + 0.5); if (std::fabs(totalBeats - snapped) < 1e-6) totalBeats = snapped; - // Split into whole beats + a fractional remainder (0..1 of a beat). double wholeBeats = std::floor(totalBeats); double frac = totalBeats - wholeBeats; - // Bars/beats are 1-based; beat cycles 1..timeSigNum within a bar. const long wb = static_cast(wholeBeats); - const long bar = wb / m.timeSigNum + 1; // 1-based bar - const long beat = wb % m.timeSigNum + 1; // 1-based beat within the bar + const long bar = wb / m.timeSigNum + 1; + const long beat = wb % m.timeSigNum + 1; - // Subdivision: hundredths of a beat, floored (0..99). A decorative display quantum. int sub = static_cast(std::floor(frac * 100.0)); if (sub < 0) sub = 0; if (sub > 99) sub = 99; @@ -48,12 +41,10 @@ std::string formatSecondsMs(double lengthSeconds) { double len = lengthSeconds > 0.0 ? lengthSeconds : 0.0; long secs = static_cast(std::floor(len)); - // Round to the nearest millisecond (not floor): FP error means 62.037 s stores as - // 62.0369999... and a raw floor would render "62.036". +0.5 before truncation rounds - // to the closest ms, which is what a wall-clock read-out should show. + // Round to nearest ms, not floor: FP storage error would otherwise render e.g. "62.036" + // for a value that should read "62.037". int ms = static_cast((len - static_cast(secs)) * 1000.0 + 0.5); - // Rounding can push ms to 1000 at a whole-second boundary; carry into seconds. - if (ms >= 1000) { ms -= 1000; ++secs; } + if (ms >= 1000) { ms -= 1000; ++secs; } // rounding can carry into the next second if (ms < 0) ms = 0; char buf[48]; diff --git a/src/core/ui/card_meta.h b/src/core/ui/card_meta.h index a561cd3..c314690 100644 --- a/src/core/ui/card_meta.h +++ b/src/core/ui/card_meta.h @@ -1,23 +1,14 @@ #pragma once -// card_meta — pure formatting for the L7 decorative card metadata overlay. Each bank -// card overlays capture length as bars.beats.subdivisions (bottom-LEFT, musical) and -// seconds.milliseconds (bottom-RIGHT, wall-clock). Both read-outs are DECORATIVE and -// non-interactive; the bank_panel draws them via the L1 kit. The formatting itself is -// pure string work over the sample's stamped tempo + meter + length, so it is -// unit-tested outside the DAW (CLAUDE.md §load-bearing split). -// -// PURE MODULE: NO REAPER types, NO SWELL, NO LICE, NO vendor/ includes. Standard -// library only. Mirror of tooltip's prefix-strip helper. +// card_meta — formatting for the bank card's decorative metadata overlay: capture length as +// bars.beats.subdivisions (bottom-left, musical) and seconds.milliseconds (bottom-right, +// wall-clock). Both are non-interactive; bank_panel draws them via the kit. #include namespace reasampler::ui { -// The musical length inputs, taken straight off a Sample (L7 F1 capture-time stamp): -// lengthSeconds — captured length in wall-clock seconds (>= 0). -// tempoBpm — project tempo (BPM) at capture (Sample.captureTempo); 0 = unknown. -// timeSigNum — meter numerator at capture (Sample.captureTimeSigNum); 0 = unstamped. -// timeSigDenom — meter denominator at capture (Sample.captureTimeSigDenom); 0 = unstamped. +// Musical length inputs, taken straight off a Sample's capture-time stamp. tempoBpm 0 = unknown; +// timeSigNum/Denom 0 = unstamped. struct MusicalLength { double lengthSeconds = 0.0; double tempoBpm = 0.0; @@ -25,31 +16,16 @@ struct MusicalLength { int timeSigDenom = 0; }; -// bars.beats.subdivisions from a capture-time tempo + meter stamp (musical read-out). +// bars.beats.subdivisions from a capture-time tempo + meter stamp. // -// Derivation: one quarter-note lasts 60 / tempo seconds; a beat in this meter lasts -// (4 / timeSigDenom) quarter-notes; a bar holds timeSigNum beats. From lengthSeconds we -// get total beats, split into whole bars (÷ timeSigNum) + whole leftover beats + a -// subdivision remainder scaled to 1..N of the next beat. The output is 1-BASED and -// zero-padded to two subdivision digits: "1.1.00" is exactly one bar-start (a -// zero-length or bar-aligned capture), "2.3.50" is 1 bar + 2 beats + half a beat. -// -// Contract / edge cases (all tested): -// * UNSTAMPED meter (timeSigNum <= 0 || timeSigDenom <= 0) OR unknown tempo -// (tempoBpm <= 0): returns "" — no musical read-out is derivable (the caller keeps -// the s.ms read-out). This is the pre-L7-sample fallback (blank musical read-out). -// * zero length: "1.1.00" (bar 1, beat 1, no subdivision) — the musical origin. -// * exact bar boundary: the beat rolls to 1 and the bar increments (never "1.5.00" -// in 4/4 — that reads as "2.1.00"). -// * long captures: bars grow without cap ("129.1.00" is fine). -// The subdivision is 0..99 (hundredths of a beat), floored — a display quantum, not a -// tick-accurate PPQ (the model refuses to invent PPQ; this is a decorative read-out). +// 1-based, zero-padded to two subdivision digits: "1.1.00" is a bar-aligned/zero-length capture, +// "2.3.50" is 1 bar + 2 beats + half a beat. Unstamped meter or unknown tempo (tempoBpm <= 0) +// returns "" — no musical read-out is derivable, caller keeps the s.ms read-out. Subdivision is +// 0..99 (hundredths of a beat), floored — a display quantum, not tick-accurate PPQ. std::string formatBarsBeats(const MusicalLength& m); // seconds.milliseconds from a wall-clock length (always derivable, meter-independent). -// * "S.mmm" — integer seconds, a dot, zero-padded 3-digit milliseconds (rounded to nearest ms). -// e.g. 0.0 -> "0.000", 1.5 -> "1.500", 62.037 -> "62.037". -// * negative length is clamped to "0.000" (a length is never negative; defensive). +// "S.mmm", rounded to nearest ms. Negative length clamps to "0.000". std::string formatSecondsMs(double lengthSeconds); } // namespace reasampler::ui diff --git a/src/core/ui/component_geometry.cpp b/src/core/ui/component_geometry.cpp index 3e9d521..6157990 100644 --- a/src/core/ui/component_geometry.cpp +++ b/src/core/ui/component_geometry.cpp @@ -1,5 +1,4 @@ -// component_geometry — pure implementation. See component_geometry.h. NO REAPER / SWELL / -// LICE / vendor. Standard library only. +// component_geometry — pure implementation. See component_geometry.h. #include "core/ui/component_geometry.h" @@ -19,14 +18,13 @@ KitButtonBox computeButtonBox(const KitBox& cell, int padding) { b.y = cell.y + padding; b.width = cell.width - 2 * padding; b.height = cell.height - 2 * padding; - if (b.empty()) return {}; // padding collapsed the cell -> suppress + if (b.empty()) return {}; return KitButtonBox{b}; } SliderGeometry computeSlider(const KitBox& control, double value, int handleSize, int trackThickness) { if (control.empty() || handleSize <= 0 || trackThickness <= 0) return {}; - // The handle must fit in both axes; too small -> nothing sensible to draw. if (control.width < handleSize || control.height < handleSize) return {}; if (value < 0.0) value = 0.0; @@ -34,8 +32,6 @@ SliderGeometry computeSlider(const KitBox& control, double value, const int half = handleSize / 2; - // Track: horizontally inset by half the handle at each end so the handle's centre - // travels only within the control; vertically centred at trackThickness. KitBox track; track.x = control.x + half; track.width = control.width - handleSize; // travel span for the handle centre @@ -43,7 +39,6 @@ SliderGeometry computeSlider(const KitBox& control, double value, track.height = trackThickness; track.y = control.y + (control.height - trackThickness) / 2; - // Handle centre travels [track.x, track.x + track.width]; its box is centred on that. const int centre = track.x + static_cast(value * track.width + 0.5); KitBox handle; handle.x = centre - half; @@ -51,7 +46,6 @@ SliderGeometry computeSlider(const KitBox& control, double value, handle.width = handleSize; handle.height = handleSize; - // Filled portion: from the track's left up to the handle centre. KitBox filled; filled.x = track.x; filled.y = track.y; @@ -79,24 +73,22 @@ double sliderValueAt(int px, const KitBox& control, int handleSize) { ListRowBox computeListRow(const KitBox& list, int index, int rowHeight) { if (list.empty() || rowHeight <= 0 || index < 0) return {}; const int top = list.y + index * rowHeight; - // Fully below the list bottom -> clipped away entirely -> no box. if (top >= list.y + list.height) return {}; KitBox b; b.x = list.x; b.y = top; b.width = list.width; - b.height = rowHeight; // a partially-visible last row keeps full height; caller clips + b.height = rowHeight; return ListRowBox{index, b}; } int hitTestListRow(int px, int py, const KitBox& list, int rowHeight, int rowCount) { if (list.empty() || rowHeight <= 0 || rowCount <= 0) return -1; - // Outside the list band entirely. if (px < list.x || px >= list.x + list.width || py < list.y || py >= list.y + list.height) return -1; const int row = (py - list.y) / rowHeight; - if (row < 0 || row >= rowCount) return -1; // in the empty tail past the last row + if (row < 0 || row >= rowCount) return -1; return row; } diff --git a/src/core/ui/component_geometry.h b/src/core/ui/component_geometry.h index 7455c47..89876e6 100644 --- a/src/core/ui/component_geometry.h +++ b/src/core/ui/component_geometry.h @@ -1,100 +1,64 @@ #pragma once #include "core/ui/rect.h" -// component_geometry — the REAPER-free, LICE-free geometry + hit-test math for the shared -// drawing kit's generic components (Phase L, L1): a button box, a slider's track/handle, -// and a list row. These are the kit-level primitives that DON'T already have a pure owner: -// bank_grid / mode_switch / tab_strip / prune_button stay the source of truth for the -// surfaces THEY own; this module carries only the new, reusable component +// component_geometry — geometry + hit-test math for the shared drawing kit's generic components: +// a button box, a slider's track/handle, and a list row. bank_grid / tab_strip / prune_button +// stay the source of truth for the surfaces they own; this carries only the reusable component // shapes the kit's drawButton / drawSlider / drawListRow draw against. -// -// Why pure (CLAUDE.md §load-bearing split, DS-1 caution): even where the draw shell reuses -// a WDL/vwnd drawing idiom, the hit-test geometry stays HERE, unit-tested outside the DAW — -// vwnd's retained-mode controls own their hit-test internally, which this deliberately does -// NOT import. The shell asks this module where a handle is and whether a point hit a row. -// -// NAME NOTE (brief §name-collision): the surrounding modules already own ButtonRect / -// SegmentRect / CellRect / FooterRect etc. in this namespace, so this module's types are -// named KitButtonBox / SliderGeometry / ListRowBox to avoid collision — checked with grep -// before minting. They are distinct concepts (kit-generic component boxes vs. a specific -// surface's hit rects), so the separate names are correct, not merely non-colliding. -// -// PURE MODULE: NO REAPER types, NO SWELL, NO LICE, NO vendor/ includes. Standard library -// only. Builds and unit-tests without REAPER. Mirror of mode_switch / prune_button. namespace reasampler::ui { -// A generic pixel box, top-left origin (SWELL/LICE convention). Shared shape for the kit -// component rects below. A zero-area box (empty()) means "nothing to draw / hit" — the -// same graceful-suppression convention prune_button uses. -using KitBox = Rect; // Q-W1: the shared concrete ui::Rect (core/ui/rect.h), role-aliased +// A generic pixel box, top-left origin. empty() means "nothing to draw / hit". +using KitBox = Rect; -// True iff (px, py) falls inside `box`, half-open bounds [x, x+width) x [y, y+height) — -// the same discipline as every sibling hit-test so draw and hit-test never double-claim a -// pixel. An empty box claims no point (always false). +// Half-open bounds [x, x+width) x [y, y+height); an empty box claims no point. bool hitTestBox(int px, int py, const KitBox& box); // --- Button ------------------------------------------------------------------ -// -// A button drawn inside a host cell, inset by a uniform padding so it reads as a raised -// control rather than a full-bleed fill (the kit's drawButton draws the micro-gradient -// surface inside this box). Distinct from prune_button, which owns its OWN placement -// within its strip — this is the generic "given a cell, where's the -// button" helper for new kit consumers. + +// A button drawn inside a host cell, inset by uniform padding so it reads as raised rather than +// full-bleed. Distinct from prune_button, which owns its own placement within its strip. struct KitButtonBox { KitBox box; bool operator==(const KitButtonBox& o) const { return box == o.box; } }; -// The button box inside `cell`, inset uniformly by `padding` on all four sides. Returns an -// empty box (suppressed) when the cell is degenerate or the padding would collapse it to -// zero-or-negative area — the caller then draws nothing (graceful, mirrors prune_button). -// padding < 0 is treated as 0. +// Button box inside `cell`, inset uniformly by `padding`. Returns an empty box (suppressed) when +// the cell is degenerate or padding would collapse it to zero-or-negative area. padding < 0 -> 0. KitButtonBox computeButtonBox(const KitBox& cell, int padding); // --- Slider (horizontal) ----------------------------------------------------- -// -// A horizontal slider: a track spanning the control width (inset at both ends by the -// handle's half-width so the handle never clips past the track), and a square handle -// centered on the track and positioned by the normalized value. drawSlider draws the -// track, the filled portion up to the handle, and the handle. Hit-test is against the -// handle (grab) and the track (jump); both are pure here. + +// track spans the control width, inset at both ends by half the handle width so the handle never +// clips past it. handle is centered on the track, positioned by the normalized value. struct SliderGeometry { - KitBox track; // the full track rect (the groove) - KitBox filled; // the filled portion from the track's left up to the handle center - KitBox handle; // the draggable handle rect + KitBox track; + KitBox filled; // filled portion from track's left up to the handle center + KitBox handle; bool operator==(const SliderGeometry& o) const { return track == o.track && filled == o.filled && handle == o.handle; } }; -// Lays out a horizontal slider inside `control` for a normalized `value` in [0, 1] with a -// square handle of side `handleSize`. The track is vertically centered at a fixed -// `trackThickness`, inset horizontally by handleSize/2 at each end so the handle's travel -// stays within `control`. value is clamped to [0, 1]; a value of 0 puts the handle flush -// left, 1 flush right. Returns all-empty boxes when the control is degenerate or too -// small to host the handle (control width < handleSize or height < handleSize) — the -// caller draws nothing. handleSize <= 0 or trackThickness <= 0 also yields empty. +// Lays out a horizontal slider inside `control` for normalized `value` in [0, 1] with a square +// handle of side `handleSize`, track vertically centered at `trackThickness`. value clamps to +// [0, 1]. Returns all-empty boxes when the control is too small to host the handle, or when +// handleSize/trackThickness <= 0. SliderGeometry computeSlider(const KitBox& control, double value, int handleSize, int trackThickness); -// The normalized value [0, 1] a click at px maps to, for a slider laid out in `control` -// with `handleSize` (the inverse of computeSlider's handle placement — a track jump). -// px left of / at the track start yields 0.0, at/right of the track end yields 1.0, -// linear in between. Returns 0.0 for a degenerate/too-small control (no travel). py is -// unused (a horizontal slider maps X only); the caller gates the whole slider region -// with hitTestBox(control) before calling this. +// Inverse of computeSlider's handle placement (a track-jump click): normalized value [0, 1] a +// click at px maps to. Clamps to [0, 1] outside the track; 0.0 for a degenerate/too-small +// control. py unused (horizontal slider maps X only) — caller gates with hitTestBox(control) first. double sliderValueAt(int px, const KitBox& control, int handleSize); // --- List row ---------------------------------------------------------------- -// -// A single selectable row in a vertical list: full-width, fixed height, stacked from the -// list's top by index (no scroll — the caller offsets the list origin for scroll). The -// kit's drawListRow draws the row surface (rest/hover/selected/focus) and an optional -// leading thumbnail; the panel's waveform cell is a specialization drawn the same way. + +// One selectable row: full-width, fixed height, stacked from the list's top by index (no scroll +// — caller offsets the list origin for that). struct ListRowBox { - int index = 0; // the row's index in the caller's list (0-based, top-first) + int index = 0; KitBox box; bool operator==(const ListRowBox& o) const { @@ -102,28 +66,20 @@ struct ListRowBox { } }; -// The row box for `index` in a list laid out inside `list` at `rowHeight` per row. Rows -// stack from list.y; row i spans [list.y + i*rowHeight, +rowHeight). Returns an empty box -// when the list is degenerate, rowHeight <= 0, index < 0, or the row would fall entirely -// below the list's bottom (fully clipped) — a partially-visible last row IS returned (the -// caller clips the draw). This is layout only; the caller decides how many rows exist. +// Row box for `index` inside `list` at `rowHeight` per row; rows stack from list.y. Empty when +// the list is degenerate, rowHeight <= 0, index < 0, or the row falls entirely below the list's +// bottom. A partially-visible last row IS returned — caller clips the draw. ListRowBox computeListRow(const KitBox& list, int index, int rowHeight); -// The index of the row a point (px, py) lands on, for a list laid out inside `list` at -// `rowHeight`. Returns -1 for a miss: outside the list bounds, in the list band but below -// the last row of `rowCount` rows (the empty tail), or a degenerate list/rowHeight/count. -// rowCount bounds the hit so a click in blank space past the last row is a clean miss, not -// a phantom row. Half-open bounds match computeListRow so the hit maps to the drawn row. +// Index of the row a point lands on, or -1 for a miss (outside bounds, or in the empty tail past +// `rowCount` rows). rowCount bounds the hit so blank space past the last row is a clean miss. int hitTestListRow(int px, int py, const KitBox& list, int rowHeight, int rowCount); // --- Waveform column count --------------------------------------------------- -// -// The number of pixel columns drawWaveform renders inside `box` (its fixed 2px side -// insets), never negative. Callers pass this count directly as the `binCount` argument to -// peaks::computeEnvelope — one bin per column is the correct resolution, and -// peaks::columnMinMax's exact partition makes the render gap-free at any bins-to-pixels -// ratio. Overbinning does NOT improve render quality (columnMinMax's frame union is -// identical whether bins == columns or bins == k*columns) and wastes memory and CPU. + +// Pixel columns drawWaveform renders inside `box` (its fixed 2px side insets), never negative. +// Pass directly as peaks::computeEnvelope's binCount — one bin per column is correct resolution; +// overbinning doesn't improve render quality and wastes memory/CPU. int waveformColumnCount(const KitBox& box); } // namespace reasampler::ui diff --git a/src/core/ui/drag_out.cpp b/src/core/ui/drag_out.cpp index 06a10e3..1a84c94 100644 --- a/src/core/ui/drag_out.cpp +++ b/src/core/ui/drag_out.cpp @@ -1,4 +1,4 @@ -// drag_out — pure implementation. See drag_out.h. NO REAPER / SWELL / OS / vendor. +// drag_out — pure implementation. See drag_out.h. #include "core/ui/drag_out.h" @@ -8,7 +8,6 @@ namespace reasampler::ui { namespace { -// Half-open point-in-rect (matches the panel's other hit-tests: [x, x+w) x [y, y+h)). bool insideClient(int px, int py, const PanelClientRect& c) { return px >= c.x && px < c.x + c.width && py >= c.y && py < c.y + c.height; @@ -20,10 +19,8 @@ DragGesture decideGesture(int px, int py, const PanelClientRect& client, const DragState& state) { if (!state.dragging || !state.hasArmedSamples) return DragGesture::None; if (insideClient(px, py, client)) return DragGesture::Internal; - // Outside the client rect (M11 boundary), refined by S17: a SINGLE-capture drag that is - // still over REAPER's own UI is an instrument drop (heading for a track's FX button); - // anything else (a multi-capture payload, or the pointer off REAPER entirely) is the - // unchanged M11 OS drag-out. + // Outside the client: a single-capture drag still over REAPER's own UI is an instrument + // drop; anything else (multi-capture, or pointer off REAPER entirely) is an OS drag-out. if (state.singleCapture && state.overReaperUi) return DragGesture::InstrumentDrop; return DragGesture::OsDrag; } @@ -34,15 +31,15 @@ PathList assemblePathList(const std::vector& resolved) { seen.reserve(resolved.size()); for (const ResolvedSample& s : resolved) { - if (s.absolutePath.empty()) { // shell could not resolve it + if (s.absolutePath.empty()) { ++out.skippedUnresolved; continue; } - if (!s.fileExists) { // stale index entry, file gone + if (!s.fileExists) { ++out.skippedMissing; continue; } - if (!seen.insert(s.absolutePath).second) { // already emitted this path + if (!seen.insert(s.absolutePath).second) { ++out.skippedDuplicate; continue; } diff --git a/src/core/ui/drag_out.h b/src/core/ui/drag_out.h index aee7a2e..b859945 100644 --- a/src/core/ui/drag_out.h +++ b/src/core/ui/drag_out.h @@ -1,31 +1,17 @@ #pragma once #include "core/ui/rect.h" -// drag_out — the REAPER-free / OS-free decision logic behind the bank_panel's native OS -// drag-out (Milestone 11, the final polish point). Two pure concerns live here so they are -// unit-tested outside the DAW (CLAUDE.md §load-bearing split); the OLE / SWELL initiation -// and the bank_panel gesture hook stay in the shell (drag_out_win.* + shell/panel/panel_drag.cpp). +// drag_out — decision logic behind the bank_panel's native OS drag-out. OLE/SWELL initiation and +// the panel gesture hook stay in the shell (drag_out_win.* + shell/panel/panel_drag.cpp). // -// 1. GESTURE BOUNDARY (invariant #4 — do not regress the internal drag). The panel -// already runs an INTERNAL drag: press a selected cell, cross a threshold, drop onto -// a pool/banks region or a tab to move/copy the samples between banks. That drag lives -// entirely INSIDE the panel client rect. The OS drag is a DISTINCT gesture with a -// distinct, discoverable boundary: while a drag is armed with samples in the payload, -// the moment the pointer LEAVES the panel client area the gesture becomes OS-bound — -// the payload is being dragged out to another window / Explorer / another DAW. Inside -// the client area it stays internal; with no armed samples there is no drag at all. -// This function is that decision, pure over (drag state + pointer + panel rect). +// Gesture boundary: the panel's own internal drag (press a selected cell, drop onto a pool/bank +// region or tab) lives entirely inside the panel client rect. The moment the pointer LEAVES that +// rect while a drag is armed with samples, the gesture becomes OS-bound — dragged out to another +// window/Explorer/DAW. A single-capture drag that leaves the rect but is still over REAPER's own +// UI is instead an InstrumentDrop (heading for a track's FX button); do not regress this boundary. // -// 2. PATH-LIST ASSEMBLY. The OS drop carries absolute file paths (Windows CF_HDROP / -// macOS file-list pasteboard). Turning the armed sample ids into that path list — -// resolving each id to its already-on-disk bank file, de-duping, and applying an -// explicit skip-missing-file policy — is pure string work over a resolver the shell -// supplies (the shell owns the REAPER project-dir read + resolveBankFile; this module -// owns the set algebra and the result contract). NO temp files: the bank files already -// exist; the list points straight at them (COPY-ONLY is enforced at the OS layer — see -// drag_out_win — never by relocating or copying bytes here). -// -// PURE MODULE: NO REAPER types, NO SWELL, NO OS/OLE, NO vendor/ includes. Standard library -// only. Builds and unit-tests without REAPER. Mirror of mode_switch. +// Path-list assembly: turns armed sample ids into the absolute path list the OS drop carries +// (Windows CF_HDROP / macOS file-list pasteboard) — set algebra only; the shell resolves each id +// to its on-disk bank file. No temp files; copy-only is enforced at the OS layer (drag_out_win). #include #include @@ -34,99 +20,53 @@ namespace reasampler::ui { // --- Gesture boundary --------------------------------------------------------- -// The panel's client rectangle in its own client coordinates (top-left origin, the SWELL/ -// LICE convention). width/height are the extents; a point (px, py) is INSIDE when -// x <= px < x + width and y <= py < y + height (half-open, matching the panel's other -// hit-tests so the edge is claimed consistently). -using PanelClientRect = Rect; // Q-W1: the shared concrete ui::Rect (core/ui/rect.h), role-aliased +// The panel's client rect, own client coords, top-left origin. Half-open: [x, x+width) x +// [y, y+height). +using PanelClientRect = Rect; -// The live drag state the shell tracks, reduced to what the boundary decision needs: -// whether a drag is currently active (threshold crossed) and whether the armed payload -// carries at least one sample. (Pre-threshold "armed but not yet dragging" is NOT a drag -// for this decision — the shell only asks once a drag is under way.) -// -// S17 (drop-and-load) adds two inputs that refine the OUTSIDE-the-panel decision without -// touching the INSIDE decision (the internal bank-to-bank drag stays byte-identical): -// * singleCapture — the payload holds EXACTLY ONE sample id. Only a single-capture drag -// arms the InstrumentDrop gesture (per the S17 open-question lean: a multi-capture drag -// over an FX button is NOT an instrument drop — it falls through to OsDrag, the natural -// multi-file drag-out to Explorer/another DAW). REJECT, not load-first: the whole gesture -// is "make ONE capture a playable instrument", so a multi payload is out of contract here. -// * overReaperUi — a SHELL-SUPPLIED predicate: true when the pointer, though outside the -// panel client rect, is still over REAPER's OWN window/UI (the shell owns the REAPER -// hit query, e.g. GetThingFromPoint; the pure layer owns only the set/boundary algebra). -// Both default false, so an M11-era caller that fills only {dragging, hasArmedSamples} gets -// EXACTLY the M11 behavior: outside the client rect with overReaperUi=false -> OsDrag. +// Live drag state reduced to what the boundary decision needs. Pre-threshold "armed but not yet +// dragging" is not a drag for this decision. struct DragState { - bool dragging = false; // threshold crossed; a drag is in progress - bool hasArmedSamples = false; // the drag payload holds >= 1 sample id - bool singleCapture = false; // S17: payload holds EXACTLY one sample (arms InstrumentDrop) - bool overReaperUi = false; // S17: pointer is over REAPER's own UI (shell-supplied) + bool dragging = false; // threshold crossed; a drag is in progress + bool hasArmedSamples = false; // payload holds >= 1 sample id + bool singleCapture = false; // payload holds EXACTLY one sample (arms InstrumentDrop) + bool overReaperUi = false; // pointer is over REAPER's own UI (shell-supplied) }; // What the shell should do with the drag given the current pointer position. enum class DragGesture { - None, // no drag under way, or an empty payload — do nothing - Internal, // dragging inside the panel — the existing bank-to-bank move/copy drag - InstrumentDrop, // S17: single-capture drag left the panel but is over REAPER's UI — - // the shell hover-tracks the TCP FX button and, on release, adds a - // ReaSampler 9000 instance preloaded with the dragged capture. - OsDrag, // dragging with samples, pointer left REAPER entirely — hand off to the OS + None, // no drag under way, or an empty payload + Internal, // dragging inside the panel — bank-to-bank move/copy + InstrumentDrop, // single-capture drag left the panel but is over REAPER's UI — shell + // hover-tracks the TCP FX button; on release adds a preloaded instance + OsDrag, // dragging with samples, pointer left REAPER entirely — hand to the OS }; -// Decides the gesture for a drag at pointer (px, py) over `client`, given `state`. -// * Not dragging (or no armed samples): None — the shell ignores the move. -// * Dragging with samples, pointer INSIDE the client rect: Internal — unchanged -// bank-to-bank behavior (invariant #4: the internal drag stays byte-identical). -// * Dragging OUTSIDE the client rect, SINGLE capture, over REAPER's UI: InstrumentDrop — -// the drag is heading for a track's FX button (S17); the shell hover-tracks + highlights. -// * Dragging OUTSIDE the client rect otherwise (multi-capture, OR the pointer has left -// REAPER entirely): OsDrag — the samples are leaving to the OS; the shell initiates the -// native OS drag with the resolved paths. -// The INSIDE decision is untouched (M11 internal drag is byte-identical). The M11 boundary -// (left the client rect -> OsDrag) is REFINED, not replaced: leaving the rect now asks -// "single-capture and over REAPER's UI -> InstrumentDrop, else -> OsDrag" — so the M11 -// OS-drag-out (multi payload, or pointer off REAPER) keeps its exact behavior. Position-only -// + state-only (no hidden state), so re-entry back inside returns Internal. +// Decides the gesture for a drag at pointer (px, py) over `client`, given `state`. Position-only +// + state-only (no hidden state), so re-entry back inside always returns Internal. DragGesture decideGesture(int px, int py, const PanelClientRect& client, const DragState& state); // --- Path-list assembly ------------------------------------------------------- -// One armed sample reduced to what path assembly needs: the resolved ABSOLUTE file path -// the shell computed for it (empty when the shell could not resolve it — e.g. no project -// dir / empty relative path). The shell resolves each via the SAME machinery the panel -// already uses for audition/insert (resolveBankFile over the current project dir), so the -// drag points at the real bank file — no temp copy. +// One armed sample reduced to what path assembly needs: the shell-resolved absolute path (empty +// if unresolvable) and whether it exists on disk. struct ResolvedSample { - std::string absolutePath; // resolved absolute path, or "" when unresolvable - bool fileExists = false; // shell stat() result — drives the skip-missing policy + std::string absolutePath; + bool fileExists = false; }; -// The outcome of assembling the drag's path list: the de-duped, existing-only absolute -// paths to hand to the OS, plus explicit tallies so the shell can decide whether to -// initiate at all (an empty `paths` means nothing draggable — do NOT start a drag). +// Outcome of assembling the drag's path list. An empty `paths` means nothing draggable — do not +// start a drag. struct PathList { - std::vector paths; // de-duped, existing files, in first-seen order - int skippedMissing = 0; // resolved but file did not exist (skip policy) - int skippedUnresolved = 0; // shell could not resolve a path at all + std::vector paths; // de-duped, existing files, first-seen order + int skippedMissing = 0; // resolved but file doesn't exist (stale index entry) + int skippedUnresolved = 0; // shell couldn't resolve a path at all int skippedDuplicate = 0; // same absolute path seen more than once }; -// Assembles the drag path list from the resolved samples (in selection order). -// Policy (all explicit, all tested): -// * SKIP-MISSING: a sample whose file does not exist on disk is skipped (counted in -// skippedMissing) — a stale index entry must never put a dangling path on the OS -// clipboard. This is the deliberate skip policy the brief asks be made explicit. -// * SKIP-UNRESOLVED: an empty absolutePath (shell could not resolve) is skipped -// (skippedUnresolved) — same reasoning, no empty entry reaches the OS. -// * DEDUPE: the same absolute path appearing twice (two index entries, one file — the -// cross-bank copy case) yields ONE CF_HDROP entry (skippedDuplicate counts the extras), -// so the OS never sees a duplicate drop path. First occurrence wins; order preserved. -// * EMPTY SELECTION: an empty input yields an empty PathList (all tallies zero) — the -// shell reads paths.empty() and does not start a drag. -// Comparison is exact-string (the shell normalizes slashes/case upstream if it wants -// case-insensitive dedup on Windows — the pure layer does not guess a platform rule). +// Assembles the drag path list from the resolved samples (selection order). Comparison is +// exact-string — the shell normalizes case/slashes upstream if it wants Windows-style dedup. PathList assemblePathList(const std::vector& resolved); } // namespace reasampler::ui diff --git a/src/core/ui/footer_bar.cpp b/src/core/ui/footer_bar.cpp index e4b683f..7094938 100644 --- a/src/core/ui/footer_bar.cpp +++ b/src/core/ui/footer_bar.cpp @@ -1,4 +1,4 @@ -// footer_bar — pure implementation. See footer_bar.h. NO REAPER / SWELL / LICE / vendor. +// footer_bar — pure implementation. See footer_bar.h. #include "core/ui/footer_bar.h" @@ -6,8 +6,7 @@ namespace reasampler::ui { namespace { -// True iff a box [x, x+width) fits entirely left of `rightBound` (its right edge does not -// cross the reserved right region). A non-positive width never "fits" (nothing to place). +// True iff a box [x, x+width) fits entirely left of `rightBound`. bool fitsLeftOf(int x, int width, int rightBound) { return width > 0 && x + width <= rightBound; } @@ -26,8 +25,7 @@ FooterBarLayout computeFooterBar(const FooterRect& footer, const FooterBarSpec& const int boxH = footer.height - 2 * spec.verticalInset; if (boxH <= 0) return out; - // The right bound the LEFT group must stay clear of (prune + version region). Clamp so a - // pathologically large rightReserve never yields a negative bound. + // Clamp so a pathologically large rightReserve never yields a negative bound. int rightBound = footer.x + footer.width - spec.rightReserve; if (rightBound < footer.x) rightBound = footer.x; diff --git a/src/core/ui/footer_bar.h b/src/core/ui/footer_bar.h index fb608ca..1064b5f 100644 --- a/src/core/ui/footer_bar.h +++ b/src/core/ui/footer_bar.h @@ -1,81 +1,46 @@ #pragma once #include "core/ui/rect.h" -// footer_bar — the REAPER-free, LICE-free layout + hit-test math for the bank_panel's L4 -// footer LEFT group: the narrowed [Arrange|Design] mode toggle, its compact per-mode count -// label, and the Tail button, laid out left-to-right at the footer's left. The panel shell -// (shell/panel/) owns the SWELL window, LICE drawing, and the click dispatch (cycle tail / -// activate a mode); what is NOT DAW-bound — WHERE the toggle box, the count label, and the -// Tail button sit, and which one a click lands on — lives here so it is unit-tested outside -// the DAW (CLAUDE.md §load-bearing split). Mirror of action_bar / mode_switch / prune_button. -// -// -- Footer affordance order (L4, left -> right) ------------------------------- +// footer_bar — layout + hit-test for the bank_panel footer's LEFT group: the [Arrange|Design] +// mode toggle, its compact count label, and the Tail button, left-to-right at the footer's left. // +// Affordance order, left -> right: // [Arrange|Design] toggle . count label . Tail button . ... . Prune (rightmost, warn) +// The view/session controls group at the left; Prune stays isolated at the far right, warn- +// colored (the only byte-deleting affordance) and owned separately by prune_button — footer_bar +// reserves a right margin (rightReserve) so its own affordances never run under it. // -// The two view/session controls (mode toggle, tail) group at the LEFT as the "how this -// panel/capture behaves" cluster; Prune stays isolated at the far RIGHT, warn-colored and -// set apart (it is the only byte-deleting affordance). This module lays out the LEFT group -// ONLY — the rightmost Prune button remains owned by prune_button (computePruneButton), so -// the two never fight over the same pixels. footer_bar reserves a right margin (rightReserve) -// so its own affordances never run under the prune button's region. -// -// The mode toggle is drawn as an N-segment control (2 segments for Arrange|Design; N general). -// footer_bar returns only the toggle's BOX (fit to its text width); the shell hands that box's -// width to the pure mode_switch (computeSegmentRects / hitTestSegment) for the per-segment -// tiling and hit-test, so mode_switch stays the ONE owner of segment geometry. footer_bar -// decides the toggle's placement + overall width; mode_switch subdivides it. -// -// Naming: the rect-role family (ButtonRect / FooterRect / FooterBarRect / ...) is unified on -// the ONE concrete ui::Rect (core/ui/rect.h, Q-W1 T2-05) — the per-role names are aliases, so -// the former hand-collision bookkeeping is retired. FooterRect (prune_button) remains the -// shared input-strip spelling; this module's output/spec/hit types carry the FooterBar* prefix. -// -// PURE MODULE: NO REAPER types, NO SWELL, NO LICE, NO vendor/ includes. Standard library only. +// The toggle here is only the overall BOX; the shell hands its width to mode_switch +// (computeSegmentRects / hitTestSegment) for per-segment tiling — mode_switch stays the one +// owner of segment geometry. -#include "core/ui/prune_button.h" // FooterRect — the footer strip input type (shared, not re-minted) +#include "core/ui/prune_button.h" // FooterRect — the shared footer strip input type namespace reasampler::ui { -// One placed affordance's pixel rectangle within the footer, top-left origin. A zero-area rect -// (empty()) means "not placed" (the footer was too narrow to host it after the ones before it), -// so the shell draws/hit-tests nothing for it — graceful degradation, mirroring prune_button. -using FooterBarRect = Rect; // Q-W1: the shared concrete ui::Rect (core/ui/rect.h), role-aliased +// One placed affordance's rect, top-left origin. empty() means "not placed" (footer too narrow +// after earlier affordances claimed their space) — shell draws/hit-tests nothing for it. +using FooterBarRect = Rect; -// The laid-out footer LEFT group: the mode toggle box, the count label box, and the Tail -// button box, in left-to-right order. Any box may be empty (suppressed) when the footer is -// too narrow to fit it left of the reserved right margin — placement is greedy left-to-right, -// so an earlier affordance survives while a later one drops (the toggle is most important, -// the Tail button drops first on a very narrow footer). +// The laid-out footer LEFT group. Any box may be empty when the footer is too narrow to fit it +// left of the reserved right margin; placement is greedy left-to-right (toggle survives longest, +// Tail drops first on a very narrow footer). struct FooterBarLayout { - FooterBarRect toggle; // the [Arrange|Design] segmented control's overall box - FooterBarRect count; // the compact per-mode count label (right of the toggle) - FooterBarRect tail; // the Tail button (right of the count label) + FooterBarRect toggle; + FooterBarRect count; + FooterBarRect tail; bool operator==(const FooterBarLayout& o) const { return toggle == o.toggle && count == o.count && tail == o.tail; } }; -// Which footer LEFT-group affordance a point landed on (or None for a miss / a suppressed -// affordance). Prune is NOT here — the shell hit-tests it separately via hitTestPruneButton. +// Which footer LEFT-group affordance a point landed on. Prune is hit-tested separately via +// hitTestPruneButton. enum class FooterHit { None, Toggle, Tail }; -// Layout inputs for the footer LEFT group, in pixels. Defaults are the bank_panel footer -// metrics; the shell passes its own so draw and hit-test share ONE source of truth. -// * toggleWidth — the [Arrange|Design] toggle's overall width. Sized to fit its two -// segment labels comfortably (a NARROW control, per L4 §3 — no longer the -// full-width top header). The shell picks this to fit its text; the pure -// module treats it as a fixed input. -// * countWidth — the compact per-mode count label's width (e.g. "2 tracks"). 0 hides it. -// * tailWidth — the Tail button's width (fits "Tail: Manual 8.0s" comfortably). -// * gap — horizontal gap between adjacent affordances. -// * leftPad — inset from the footer left edge to the toggle's left edge. -// * verticalInset — top/bottom gap inside the footer so the controls read as raised, not -// full-height fills (matches prune_button's verticalInset). -// * rightReserve — pixels reserved at the footer's RIGHT for the prune button + version -// readout region; footer_bar never places an affordance whose right edge -// would cross into (footer.right - rightReserve). Keeps the LEFT group -// clear of the RIGHT prune/version region without those modules coupling. +// Layout inputs, in pixels; defaults are the bank_panel footer metrics. +// * rightReserve — pixels reserved at the footer's right for the prune button + version +// readout; footer_bar never places an affordance whose right edge would cross into it. struct FooterBarSpec { int toggleWidth = 132; int countWidth = 64; @@ -86,21 +51,14 @@ struct FooterBarSpec { int rightReserve = 168; // clears prune_button (rightInset 84 + width 72) + margin }; -// Lays out the footer LEFT group inside `footer` per `spec`, left-to-right: toggle, then the -// count label, then the Tail button, each `gap` px apart, starting at footer.left + leftPad, -// vertically centred by verticalInset. Greedy: an affordance is placed only if its whole box -// fits left of (footer.right - rightReserve); otherwise it (and, since placement is ordered, -// it alone or the ones after it) is suppressed (empty box). A degenerate footer (width/height -// <= 0) yields an all-empty layout. countWidth <= 0 suppresses the count label (and the gap -// that would precede the Tail button collapses so the Tail sits right after the toggle). +// Lays out the footer LEFT group inside `footer` per `spec`: toggle, count label, Tail button, +// each `gap` px apart from footer.left + leftPad. Greedy — an affordance places only if it fits +// left of (footer.right - rightReserve); once one doesn't fit, the rest are suppressed too. +// countWidth <= 0 suppresses the count label without leaving a gap for the Tail button. FooterBarLayout computeFooterBar(const FooterRect& footer, const FooterBarSpec& spec); -// The footer LEFT-group affordance the point (px, py) (SWELL/LICE top-left client coords) lands -// on, or FooterHit::None for a miss (outside every placed box, or on the count label — which is -// a passive readout, not a control). Half-open bounds [x, x+width) x [y, y+height) match -// computeFooterBar so draw and hit-test agree on the same pixels. An empty (suppressed) box -// never claims a point. The shell checks the toggle hit FIRST for a segment sub-hit (via -// mode_switch over the toggle box), then the Tail hit; this returns which region was struck. +// The affordance (px, py) lands on, or FooterHit::None for a miss (or a hit on the count label, +// a passive readout, never a control). Half-open bounds match computeFooterBar. FooterHit hitTestFooterBar(int px, int py, const FooterBarLayout& layout); } // namespace reasampler::ui diff --git a/src/core/ui/mode_enable.cpp b/src/core/ui/mode_enable.cpp index 0f31bd8..b06ef01 100644 --- a/src/core/ui/mode_enable.cpp +++ b/src/core/ui/mode_enable.cpp @@ -1,18 +1,16 @@ -// mode_enable — pure implementation. See mode_enable.h. NO REAPER / SWELL / LICE / vendor. +// mode_enable — pure implementation. See mode_enable.h. #include "core/ui/mode_enable.h" -#include "core/view/view_mode_model.h" // kArrangeModeId / kDesignModeId — the ONE home for the mode ids +#include "core/view/view_mode_model.h" // kArrangeModeId / kDesignModeId namespace reasampler::ui { bool tagButtonEnabled(const std::string& activeModeId, TagTarget target) { - // The target's own mode id, so the rule is a single "target != active" compare. const char* targetId = (target == TagTarget::Arrange) ? kArrangeModeId : kDesignModeId; - // Fail-open on an unrecognized active id (neither seed mode): every button live, so a - // future added mode never dead-locks the bar and the user can always reach the action. + // Fail-open on an unrecognized active id: every button live. if (activeModeId != kArrangeModeId && activeModeId != kDesignModeId) return true; return activeModeId != targetId; diff --git a/src/core/ui/mode_enable.h b/src/core/ui/mode_enable.h index 0132f5e..16662bf 100644 --- a/src/core/ui/mode_enable.h +++ b/src/core/ui/mode_enable.h @@ -1,39 +1,22 @@ #pragma once -// mode_enable — the REAPER-free opposite-mode enablement predicate behind the bank_panel BOTTOM -// toolbar's four Item/Track × Arrange/Design tag buttons (Phase L, L5, refinement 3). Each tag -// button sends the selection to a TARGET mode; a button is meaningful ONLY when its target is -// the OPPOSITE of the currently active mode. When Design is active the two "…: Arrange" buttons -// are live and the two "…: Design" buttons are dead (already there); when Arrange is active the -// reverse. This module owns that one decision — (active mode, button target) -> live/disabled — -// as a pure predicate, unit-tested for both active modes; the shell reads the active mode from -// view().activeModeId() (the SAME source the footer toggle reads — one source of truth for -// "which mode is active") and draws the disabled buttons in the kit Disabled state. -// -// Why pure: which button is live is a decision, not a draw or a DAW behaviour. Keeping it here -// means the shell cannot drift the enablement from the rule, and both active modes are covered -// by CTest, not only whichever one a manual DAW pass happened to sit in. -// -// PURE MODULE: NO REAPER types, NO SWELL, NO LICE, NO vendor/ includes. Standard library only. +// mode_enable — enablement predicate behind the bank_panel bottom toolbar's four Item/Track x +// Arrange/Design tag buttons. A tag button sends the selection to a TARGET mode; it's live only +// when its target differs from the currently active mode (you tag INTO the mode you're not in). #include namespace reasampler::ui { -// A tag button's TARGET mode — the mode it sends the selection to when fired. Arrange = the -// untagged default (returning the selection to Arrange), Design = tagged into the Design mode. -// The Item/Track axis is orthogonal to enablement (both Item and Track buttons for a target -// enable/disable together), so it is NOT modelled here — the shell carries it per button. +// A tag button's TARGET mode. The Item/Track axis is orthogonal to enablement (both buttons for +// a target enable/disable together), so it isn't modelled here — the shell carries it per button. enum class TagTarget { Arrange, Design, }; -// True iff a tag button whose target is `target` should be LIVE (clickable), given the active -// mode id `activeModeId` (as returned by ViewModeModel::activeModeId() — the mode ids are the -// pure `kArrangeModeId` / `kDesignModeId` constants). The rule: a button is live iff its target -// differs from the active mode — you tag INTO the mode you are not currently in. An unrecognized -// active id (neither arrange nor design) leaves every button live (fail-open: never silently -// disable an action the user can still reach), so a future added mode never dead-locks the bar. +// True iff a button targeting `target` should be live, given the active mode id `activeModeId` +// (ViewModeModel::activeModeId(), i.e. kArrangeModeId / kDesignModeId). An unrecognized active id +// leaves every button live (fail-open — never silently disable a reachable action). bool tagButtonEnabled(const std::string& activeModeId, TagTarget target); } // namespace reasampler::ui diff --git a/src/core/ui/overflow_menu.cpp b/src/core/ui/overflow_menu.cpp index e6dd9b6..dbe1c4b 100644 --- a/src/core/ui/overflow_menu.cpp +++ b/src/core/ui/overflow_menu.cpp @@ -1,4 +1,4 @@ -// overflow_menu — pure implementation. See overflow_menu.h. NO REAPER / SWELL / LICE / vendor. +// overflow_menu — pure implementation. See overflow_menu.h. #include "core/ui/overflow_menu.h" @@ -6,8 +6,7 @@ namespace reasampler::ui { int menuButtonReserve(const MenuBarRect& bar, const MenuButtonSpec& spec) { if (bar.width <= 0 || bar.height <= 0 || spec.buttonWidth <= 0) return 0; - // The reserve is the button width plus a right gap (rightInset) and a matching left gap - // (also rightInset) so the frequent buttons have breathing room before the menu button. + // Button width plus a right gap and a matching left gap for breathing room. return spec.buttonWidth + 2 * spec.rightInset; } @@ -21,7 +20,7 @@ MenuButtonRect computeMenuButton(const MenuBarRect& bar, const MenuButtonSpec& s int top = bar.y + spec.verticalInset; int height = bar.height - 2 * spec.verticalInset; - if (height <= 0) { // thin band: clamp to the band's own extents rather than go negative + if (height <= 0) { top = bar.y; height = bar.height; } diff --git a/src/core/ui/overflow_menu.h b/src/core/ui/overflow_menu.h index 31972ee..6ad993d 100644 --- a/src/core/ui/overflow_menu.h +++ b/src/core/ui/overflow_menu.h @@ -1,44 +1,23 @@ #pragma once #include "core/ui/rect.h" -// overflow_menu — the REAPER-free layout math behind the bank_panel TOP toolbar's "⋯ / More" -// overflow-menu button (Phase L, L5, refinement 1). The rare capture variants (Batch Items / -// Batch Razor / Capture RT) move OFF the always-visible top bar into a popup opened by a small -// square button pinned to the FAR RIGHT of the top toolbar band. This module owns two things, -// both unit-tested outside the DAW: -// * WHERE the More button sits in the top toolbar band (right-anchored, vertically inset); -// * the horizontal RESERVE the action_bar must leave for it, so the frequent buttons never -// run under the menu button (the shell shrinks the action_bar's usable width by this). -// The popup itself (TrackPopupMenu) + the command dispatch is shell — a transient OS menu, not -// panel chrome (brief §1: "a REAPER/host popup menu is acceptable"). Only the button -// geometry + hit-test live here. -// -// PURE MODULE: NO REAPER types, NO SWELL, NO LICE, NO vendor/ includes. Standard library only. -// Mirror of prune_button / mode_switch. The bar rect type it consumes mirrors action_bar's -// ActionBarRect shape but is named distinctly to avoid coupling the two modules. +// overflow_menu — layout for the bank_panel top toolbar's "..." overflow-menu button: the rare +// capture variants (Batch Items / Batch Razor / Capture RT) live in a popup opened by a small +// square button right-anchored in the top toolbar band. Owns the button's placement and the +// horizontal reserve action_bar must leave so its buttons never run under it. The popup itself +// (TrackPopupMenu) and command dispatch are shell concerns. namespace reasampler::ui { -// The toolbar band the button is drawn into, top-left origin (SWELL/LICE convention). The -// shell derives this from topToolbarRect(). A distinct type from action_bar::ActionBarRect so -// this module stands alone (same shape; deliberate — the two modules are not coupled). -using MenuBarRect = Rect; // Q-W1: the shared concrete ui::Rect (core/ui/rect.h), role-aliased +// The toolbar band the button draws into, top-left origin. +using MenuBarRect = Rect; -// The More button's pixel rectangle within the band, top-left origin. A zero-area rect -// (width <= 0 or height <= 0) means "no button" — the band is degenerate or too narrow to -// place the button clear of its left inset; the caller must not draw or hit-test it. The -// three variants stay reachable via their bindable commands, so a suppressed button is -// graceful, not a lost affordance. -using MenuButtonRect = Rect; // Q-W1: the shared concrete ui::Rect (core/ui/rect.h), role-aliased +// The More button's rect. Zero-area means "no button" — the three variants stay reachable via +// their bindable commands regardless. +using MenuButtonRect = Rect; -// Layout inputs for the More button, in pixels. Defaults match the bank_panel top-toolbar -// metrics; the shell passes its own so draw and hit-test share one source of truth. -// * buttonWidth — the button's fixed width (a compact square-ish glyph button). -// * rightInset — gap from the band's right edge to the button's right edge. -// * verticalInset — top/bottom gap inside the band (shorter than the band so it reads as a -// raised control, matching the action_bar buttons' verticalInset). -// * minLeftInset — the button's left edge must stay at least this far from the band left -// edge; if it would encroach past this, computeMenuButton yields an empty -// rect (button suppressed). +// Layout inputs, in pixels; defaults match the bank_panel top-toolbar metrics. +// * minLeftInset — button's left edge must stay at least this far from the band's left edge; +// otherwise computeMenuButton suppresses it (empty rect). struct MenuButtonSpec { int buttonWidth = 28; int rightInset = 6; @@ -46,23 +25,16 @@ struct MenuButtonSpec { int minLeftInset = 40; }; -// The horizontal reserve (px) the action_bar must leave at the band's right so its buttons -// never run under the More button: the button width + both insets (right gap + a matching -// left breathing gap equal to rightInset). The shell subtracts this from the action_bar rect's -// width before laying out slots. Returns 0 for a degenerate band (nothing to reserve). +// Horizontal reserve (px) action_bar must leave at the band's right: button width + both insets. +// 0 for a degenerate band. int menuButtonReserve(const MenuBarRect& bar, const MenuButtonSpec& spec); -// Computes the More button's rect within `bar` per `spec`. Right-anchored: the button's right -// edge is bar.x + bar.width - rightInset, its width is buttonWidth, vertically centred by -// verticalInset. Returns an EMPTY rect when: the band is degenerate (width/height <= 0), the -// buttonWidth is non-positive, OR the resulting left edge would fall closer to the band left -// than minLeftInset. A thin band clamps the button height to the band's own rather than going -// negative (mirror of computePruneButton). +// The More button's rect within `bar`, right-anchored, vertically centred by verticalInset. +// Empty when the band is degenerate, buttonWidth <= 0, or the left edge would fall closer to the +// band's left than minLeftInset. A thin band clamps height to the band's own rather than negative. MenuButtonRect computeMenuButton(const MenuBarRect& bar, const MenuButtonSpec& spec); -// True iff the point (px, py) (SWELL/LICE top-left client coords) falls inside `button`. -// Half-open bounds [x, x+width) x [y, y+height) — matches computeMenuButton so draw and -// hit-test agree on the same pixels. An empty button never claims a point (always false). +// Half-open bounds, matching computeMenuButton. Empty button claims nothing. bool hitTestMenuButton(int px, int py, const MenuButtonRect& button); } // namespace reasampler::ui diff --git a/src/core/ui/prune_button.cpp b/src/core/ui/prune_button.cpp index 71db3a2..7136d49 100644 --- a/src/core/ui/prune_button.cpp +++ b/src/core/ui/prune_button.cpp @@ -1,9 +1,6 @@ #include "core/ui/prune_button.h" -// prune_button implementation — right-anchored button placement in the footer strip, -// with a left-collision suppression rule. Trivially auditable arithmetic; the safety -// property (a suppressed/empty button never claims a click) is a pure predicate tested -// outside the DAW. +// prune_button — pure implementation. See prune_button.h. namespace reasampler::ui { diff --git a/src/core/ui/prune_button.h b/src/core/ui/prune_button.h index cf7f853..c2f6343 100644 --- a/src/core/ui/prune_button.h +++ b/src/core/ui/prune_button.h @@ -1,82 +1,38 @@ #pragma once #include "core/ui/rect.h" -// prune_button — the REAPER-free layout math behind the bank_panel's Prune button -// (Phase R, Wave 3 — R3, fork R-E). A single labelled button drawn in the panel's -// tail-footer strip that fires the "Prune bank folder" command. The panel shell -// (shell/panel/) owns the SWELL window, LICE drawing, and the Main_OnCommand -// dispatch of the registered command id — all REAPER-bound, DAW-verified. What is -// NOT DAW-bound — WHERE the button sits in the footer and whether a click lands on -// it — lives here so it is unit-tested outside the DAW (CLAUDE.md §load-bearing -// split). Mirror of mode_switch / tab_strip. +// prune_button — layout for the bank_panel's Prune button in the tail-footer strip. Panel shell +// owns SWELL/LICE/dispatch; this owns whether a click lands on it. // -// PURE MODULE: NO REAPER types, NO SWELL, NO vendor/ includes. Standard library -// only. Builds and unit-tests without REAPER. -// -// -- Placement contract -------------------------------------------------------- -// -// The footer hosts (L4) a LEFT group — the [Arrange|Design] mode toggle, a per-mode -// count, and the Tail button (bank_panel footer_bar) — and a RIGHT-aligned version -// readout (bank_panel drawFooter). The prune button is a fixed-width button anchored -// to the RIGHT of the footer, inset from the right edge, sitting just LEFT of the -// version readout's inset region and set APART from the benign left group. It never -// overlaps the left group (footer_bar reserves rightReserve px at the right to match). -// When the footer is too narrow to fit the button without colliding with the left -// inset, the button is suppressed (empty rect) rather than drawn on top — the action -// is always reachable via its bindable command, so a hidden button is a graceful -// degradation, not a lost affordance. +// Placement: right-anchored in the footer, inset from the right edge, just left of the version +// readout, set apart from the footer_bar left group (mode toggle / count / Tail). Suppressed +// (empty rect) rather than drawn overlapping when the footer is too narrow — the command stays +// reachable via its binding either way. namespace reasampler::ui { -// The footer strip the button is drawn into, top-left origin (SWELL/LICE -// convention). (x, y) is the top-left corner; width/height are the strip extents. -// bank_panel derives this from panelFooter() and passes it here. -using FooterRect = Rect; // Q-W1: the shared concrete ui::Rect (core/ui/rect.h), role-aliased +using FooterRect = Rect; +using ButtonRect = Rect; -// A button's pixel rectangle within the footer, top-left origin. A zero-area rect -// (width <= 0 or height <= 0) means "no button" — the footer is too narrow to place -// it, or the footer itself is degenerate; the caller must not draw or hit-test it. -using ButtonRect = Rect; // Q-W1: the shared concrete ui::Rect (core/ui/rect.h), role-aliased - -// Layout inputs for the prune button, in pixels. Defaults match the bank_panel footer -// metrics; the shell passes its own so draw and hit-test share one source of truth. -// * buttonWidth — the button's fixed width. -// * rightInset — gap from the footer's right edge to the button's right edge (the -// button sits left of this inset, clearing the right-aligned version -// readout). COUPLED TO drawFooter (panel_render.cpp): the version readout -// uses an 8 px right margin. The button's right edge lands at -// footer.right - 84, i.e. 76 px left of the readout's right margin — -// enough clearance for the ~10-char label. ALSO COUPLED to -// FooterBarSpec::rightReserve (footer_bar.h): the L4 footer-left group -// (mode toggle + count + Tail) reserves that many px at the right so it -// never runs under this button; rightReserve must exceed rightInset + -// buttonWidth. If the version readout's inset changes in drawFooter, -// update this value to maintain clearance. -// * verticalInset — top/bottom gap inside the footer (the button is shorter than the -// strip so it reads as a raised control, not a full-height fill). -// * minLeftInset — the button's left edge must stay at least this far from the footer -// left edge (reserving room for the L4 footer-left group). If the button -// would encroach past this, computePruneButton yields an empty rect -// (button suppressed — see header placement contract). +// Layout inputs, in pixels; defaults match the bank_panel footer metrics. +// * rightInset — gap from the footer's right edge to the button's right edge, clearing the +// right-aligned version readout. COUPLED to drawFooter's version-readout +// margin (panel_render.cpp) and to FooterBarSpec::rightReserve, which must +// exceed rightInset + buttonWidth so the left group never runs under this +// button. Update together if either margin changes. +// * minLeftInset — button's left edge must stay this far from the footer left edge (room for +// the footer-left group); otherwise the button is suppressed. struct PruneButtonSpec { int buttonWidth = 72; - int rightInset = 84; // COUPLED: version readout in drawFooter uses an 8 px right margin + int rightInset = 84; int verticalInset = 4; int minLeftInset = 120; }; -// Computes the prune button's rect within `footer` per `spec`. Right-anchored: the -// button's right edge is footer.x + footer.width - rightInset, its width is buttonWidth, -// and it is vertically centred by verticalInset. Returns an EMPTY rect (button -// suppressed) when: the footer is degenerate (width/height <= 0), OR the resulting left -// edge would fall closer to the footer left than minLeftInset (too narrow to place -// without colliding with the tail label). The action stays reachable via its command in -// that case — a suppressed button is graceful, not a lost feature. +// Right-anchored rect within `footer`, vertically centred. Empty when the footer is degenerate +// or the resulting left edge would fall closer to the footer's left than minLeftInset. ButtonRect computePruneButton(const FooterRect& footer, const PruneButtonSpec& spec); -// True iff the point (px, py) (SWELL/LICE top-left client coords) falls inside `button`. -// Half-open bounds [x, x+width) x [y, y+height) — matches computePruneButton so draw and -// hit-test agree on the same pixels. An empty button never claims a point (always false), -// so a suppressed button cannot be accidentally clicked. +// Half-open bounds, matching computePruneButton. Empty button never claims a point. bool hitTestPruneButton(int px, int py, const ButtonRect& button); } // namespace reasampler::ui diff --git a/src/core/ui/rect.h b/src/core/ui/rect.h index 92e64bd..d5b48ac 100644 --- a/src/core/ui/rect.h +++ b/src/core/ui/rect.h @@ -1,23 +1,6 @@ #pragma once -// rect.h — the ONE concrete pixel rectangle (Q-W1, T2-05 ≡ T4-21). -// -// Before Q-W1 the codebase carried 12+ byte-identical {x, y, width, height} structs -// (ButtonRect / FooterRect / CellRect / KitBox / ...) plus a second LTRB grammar on -// the VST side (editor_geometry's left/top/right/bottom Rect). This is the single -// owner: one CONCRETE type (deliberately NOT a template — the role types differed in -// name only, so a template would model nothing), with per-role aliases at the old -// definition sites so call sites keep their semantic names -// (`using ButtonRect = ui::Rect;`). -// -// Grammar: XYWH storage (the majority grammar — every extension role struct), with -// right()/bottom() accessors and an ltrb() factory so the former LTRB call sites -// convert mechanically. Half-open on both axes: a rect covers -// [x, x+width) × [y, y+height) — the same convention LICE/SWELL RECTs use, and the -// one every hitTest* in the codebase already implements. -// -// PURE MODULE: standard library only. Header-only; behavior is covered by the role -// modules' own test executables (prune_button / footer_bar / bank_grid / ... and the -// instrument-ui suites), which exercise every alias against these semantics. +// rect.h — the one concrete pixel rectangle. XYWH storage, half-open on both axes: a rect +// covers [x, x+width) x [y, y+height) — matches the LICE/SWELL RECT convention. namespace reasampler::ui { @@ -27,16 +10,13 @@ struct Rect { int width = 0; int height = 0; - // Exclusive edges (half-open convention). int right() const { return x + width; } int bottom() const { return y + height; } - // A zero-or-negative-area rect means "not placed / suppressed": the caller must - // not draw or hit-test it (the shared graceful-degradation contract). + // Zero-or-negative area means "not placed / suppressed" — caller must not draw or hit-test it. bool empty() const { return width <= 0 || height <= 0; } - // The former LTRB grammar's constructor (editor_geometry and friends): edges in, - // extents stored. right/bottom exclusive, matching right()/bottom(). + // LTRB constructor for call sites that think in edges rather than extents. static Rect ltrb(int left, int top, int right, int bottom) { return Rect{left, top, right - left, bottom - top}; } @@ -47,8 +27,8 @@ struct Rect { bool operator!=(const Rect& o) const { return !(*this == o); } }; -// True iff (px, py) falls inside r under the half-open convention. An empty rect -// contains nothing, so a suppressed affordance can never claim a click. +// Half-open containment; an empty rect contains nothing, so a suppressed affordance never +// claims a click. inline bool contains(const Rect& r, int px, int py) { return px >= r.x && px < r.x + r.width && py >= r.y && py < r.y + r.height; } diff --git a/src/core/ui/tab_strip.cpp b/src/core/ui/tab_strip.cpp index c0d4db7..b05de6b 100644 --- a/src/core/ui/tab_strip.cpp +++ b/src/core/ui/tab_strip.cpp @@ -1,4 +1,4 @@ -// tab_strip — pure implementation. See tab_strip.h. NO REAPER / SWELL / vendor. +// tab_strip — pure implementation. See tab_strip.h. #include "core/ui/tab_strip.h" @@ -8,17 +8,16 @@ namespace reasampler::ui { TabStripLayout computeTabStripLayout(const TabStripRect& strip, int tabCount, const TabStripSpec& spec, int scrollOffset) { - (void)scrollOffset; // layout depends on geometry only, not the current offset + (void)scrollOffset; // layout depends on geometry only TabStripLayout out; if (tabCount <= 0 || strip.width <= 0) { out.trackX = strip.x; out.trackWidth = strip.width > 0 ? strip.width : 0; - return out; // nothing to lay out: track == strip, no overflow, no chevrons + return out; } const int totalTabsWidth = tabCount * spec.tabWidth; if (totalTabsWidth <= strip.width) { - // Everything fits: the whole strip is the track; no chevrons, no scroll. out.overflow = false; out.trackX = strip.x; out.trackWidth = strip.width; @@ -26,15 +25,12 @@ TabStripLayout computeTabStripLayout(const TabStripRect& strip, int tabCount, return out; } - // Overflow: reserve a chevron band at each end; the tabs live between them. out.overflow = true; out.leftChevron = true; out.rightChevron = true; out.trackX = strip.x + spec.chevronWidth; out.trackWidth = strip.width - 2 * spec.chevronWidth; if (out.trackWidth < 0) out.trackWidth = 0; - // The tab run exceeds the track by this many pixels; the strip may scroll exactly - // that far so the last tab's right edge reaches the track's right edge, no more. out.maxScroll = totalTabsWidth - out.trackWidth; if (out.maxScroll < 0) out.maxScroll = 0; return out; @@ -61,11 +57,9 @@ std::vector computeTabRects(const TabStripRect& strip, int tabCount, for (int i = 0; i < tabCount; ++i) { const int rawLeft = trackLeft + i * spec.tabWidth - offset; const int rawRight = rawLeft + spec.tabWidth; - // Clip to the track: a partially-scrolled tab must not draw under a chevron - // or spill past the track. A tab whose clipped extent is empty is omitted. int left = rawLeft < trackLeft ? trackLeft : rawLeft; int right = rawRight > trackRight ? trackRight : rawRight; - if (right <= left) continue; // fully scrolled out of view either side + if (right <= left) continue; // fully scrolled out of view TabRect r; r.index = i; r.x = left; @@ -79,10 +73,9 @@ std::vector computeTabRects(const TabStripRect& strip, int tabCount, TabHit hitTestTabStrip(int px, int py, const TabStripRect& strip, int tabCount, const TabStripSpec& spec, int scrollOffset) { - TabHit miss; // {None, -1} + TabHit miss; if (tabCount <= 0 || strip.width <= 0 || strip.height <= 0) return miss; - // Reject anything outside the strip band first (half-open bounds). if (px < strip.x || px >= strip.x + strip.width || py < strip.y || py >= strip.y + strip.height) return miss; @@ -90,8 +83,7 @@ TabHit hitTestTabStrip(int px, int py, const TabStripRect& strip, int tabCount, const TabStripLayout layout = computeTabStripLayout(strip, tabCount, spec, scrollOffset); - // Chevrons take precedence at the strip ends: a click in a reserved chevron band - // is a scroll, never a tab (the tab track excludes those bands). + // Chevron bands take precedence at the strip ends over any tab. if (layout.overflow) { if (px < strip.x + spec.chevronWidth) return TabHit{TabHitKind::ScrollLeft, -1}; @@ -99,14 +91,13 @@ TabHit hitTestTabStrip(int px, int py, const TabStripRect& strip, int tabCount, return TabHit{TabHitKind::ScrollRight, -1}; } - // Inside the track: find the visible tab whose clipped rect contains px. Reuse - // computeTabRects so the hit matches exactly what was drawn (clipping included). + // Reuse computeTabRects so the hit matches exactly what was drawn (clipping included). const std::vector rects = computeTabRects(strip, tabCount, spec, scrollOffset); for (const TabRect& r : rects) { if (px >= r.x && px < r.x + r.width) return TabHit{TabHitKind::Tab, r.index}; } - return miss; // track dead space (no tab under the point) + return miss; } } // namespace reasampler::ui diff --git a/src/core/ui/tab_strip.h b/src/core/ui/tab_strip.h index ea11b38..8e6f738 100644 --- a/src/core/ui/tab_strip.h +++ b/src/core/ui/tab_strip.h @@ -1,45 +1,25 @@ #pragma once #include "core/ui/rect.h" -// tab_strip — the REAPER-free layout + hit-test math behind the bank_panel's -// named-banks tab strip (Phase B, Wave 4 — B4). The named-banks region of the -// vertical-split bank window is a LICE-drawn tab strip (one tab per named bank, -// NOT a SWELL-native tab control), and — from the start — it must scroll when the -// tabs overflow the strip width (a naive fixed-width strip breaks down at ~8–12 -// tabs). What is NOT DAW-bound — how N fixed-width tabs tile a strip of a given -// pixel width, where the overflow chevrons sit, which tab/chevron a click lands in, -// and how far the strip may scroll — lives here so it is unit-tested outside the -// DAW (CLAUDE.md §load-bearing split). The panel shell (shell/panel/) owns the -// SWELL window, LICE drawing, and the live BankBook read; it calls into this seam -// for every rect and every hit. Mirror of mode_switch / bank_grid. -// -// PURE MODULE: NO REAPER types, NO SWELL, NO vendor/ includes. Standard library -// only. Builds and unit-tests without REAPER. +// tab_strip — layout + hit-test for the bank_panel's named-banks tab strip: a LICE-drawn strip +// (not a SWELL tab control) that scrolls via chevrons when tabs overflow the strip width. #include namespace reasampler::ui { -// The strip the tabs are drawn into, top-left origin (SWELL/LICE convention). -// (x, y) is the top-left corner; width/height are the strip extents. The panel -// reserves this as a fixed-height band at the top of the named-banks region. -using TabStripRect = Rect; // Q-W1: the shared concrete ui::Rect (core/ui/rect.h), role-aliased +// The strip the tabs draw into, top-left origin. +using TabStripRect = Rect; -// Fixed inputs that shape the strip. tabWidth is the pixel width of each tab (fixed -// so the strip reads as a uniform segmented control and overflow math stays simple — -// labels ellipsize within the tab, they do not resize it). chevronWidth is the width -// reserved at each end for the scroll affordance WHEN the tabs overflow; when they -// fit, no chevron is reserved and the tabs use the full strip width. +// tabWidth is fixed per tab so the strip reads as a uniform segmented control and overflow math +// stays simple (labels ellipsize, they don't resize the tab). chevronWidth is reserved at each +// end only when tabs overflow. struct TabStripSpec { int tabWidth = 96; int chevronWidth = 20; }; -// One tab's pixel rectangle within the strip, top-left origin, ALREADY translated -// by the current scroll offset and clipped to the visible track. `index` is the -// tab's index in the caller's list (ordinal order) so the shell can label/​light it -// without re-deriving. A tab scrolled fully out of view is omitted from the result -// (the shell only draws what computeTabRects returns), so every returned rect is at -// least partially visible. +// One tab's rect, already translated by scroll offset and clipped to the visible track. A tab +// scrolled fully out of view is omitted from computeTabRects's result. struct TabRect { int index = 0; int x = 0; @@ -53,59 +33,41 @@ struct TabRect { } }; -// The scrollable track's geometry: where the tabs may be drawn (between the -// chevrons when overflowing, or the whole strip when they fit) and whether each -// chevron is present. Derived once and shared by layout + hit-testing so both agree. +// Scrollable track geometry, shared by layout + hit-test so both agree. struct TabStripLayout { - bool overflow = false; // true iff N tabs at tabWidth exceed the track width - int trackX = 0; // left edge of the tab track (past the left chevron) - int trackWidth = 0; // width available to tabs (strip minus both chevrons) - int maxScroll = 0; // largest valid scroll offset (0 when no overflow) - bool leftChevron = false; // a left-scroll affordance is reserved this frame - bool rightChevron = false;// a right-scroll affordance is reserved this frame + bool overflow = false; + int trackX = 0; + int trackWidth = 0; + int maxScroll = 0; + bool leftChevron = false; + bool rightChevron = false; }; -// Computes the strip layout for `tabCount` tabs of `spec.tabWidth` in `strip`, -// given the current `scrollOffset`. Pure geometry: -// * No overflow (all tabs fit the strip width): overflow=false, no chevrons, the -// track IS the strip, maxScroll=0. -// * Overflow: both chevrons are reserved (chevronWidth each), the track is the -// strip minus both chevrons, and maxScroll is the pixels by which the tab run -// exceeds the track (so the last tab's right edge can reach the track's right -// edge but not scroll past it). Chevrons are always both present under overflow -// (a fixed affordance is simpler and unambiguous than hiding one at an end; -// clicking a chevron at a scroll limit is a harmless no-op the shell clamps). -// tabCount <= 0 or a non-positive strip width returns a zeroed layout (no overflow, -// track == strip, maxScroll 0). +// Layout for `tabCount` tabs of `spec.tabWidth` in `strip`. No overflow: track == strip, no +// chevrons, maxScroll 0. Overflow: both chevrons always reserved together (simpler than hiding +// one at a scroll limit — a chevron click there is a harmless no-op the shell clamps); track is +// the strip minus both chevrons; maxScroll is how far the tab run exceeds the track. +// tabCount <= 0 or non-positive strip width returns a zeroed layout. TabStripLayout computeTabStripLayout(const TabStripRect& strip, int tabCount, const TabStripSpec& spec, int scrollOffset); -// Clamps a desired scroll offset into [0, maxScroll] for the given layout. The shell -// calls this after a chevron click / wheel so the strip never scrolls past either -// end. maxScroll is 0 when the tabs fit, so a fitting strip always clamps to 0. +// Clamps a desired scroll offset into [0, maxScroll]; always 0 when tabs fit. int clampTabScroll(int desiredOffset, const TabStripLayout& layout); -// Tiles `tabCount` fixed-width tabs left-to-right into the layout's track, shifted -// left by `scrollOffset`, and returns the rects that are at least partially visible -// (in tab-index order). Each tab i sits at trackX + i*tabWidth - scrollOffset; a tab -// whose visible extent is empty (fully left of or right of the track) is omitted. -// Returned rects are CLIPPED to the track horizontally so a partially-scrolled tab -// does not draw under a chevron. The caller passes the SAME scrollOffset it passed -// to computeTabStripLayout (the shell clamps once, then uses the clamped value for -// both). tabCount <= 0 -> empty. +// Tiles tabCount fixed-width tabs into the track, shifted by scrollOffset, returning only +// partially-or-fully visible rects (clipped to the track so a scrolled tab never draws under a +// chevron). Caller must pass the same scrollOffset used for computeTabStripLayout. std::vector computeTabRects(const TabStripRect& strip, int tabCount, const TabStripSpec& spec, int scrollOffset); -// What a point in the strip resolves to. enum class TabHitKind { - None, // outside the strip, or in dead space between visible tabs - Tab, // a tab — `index` is the tab's index in the caller's list - ScrollLeft, // the left overflow chevron - ScrollRight, // the right overflow chevron + None, + Tab, + ScrollLeft, + ScrollRight, }; -// The outcome of hit-testing a point against the strip. For Tab, `index` is the tab -// index; for the chevrons and None it is -1. +// index is the tab's index for Tab, -1 for chevrons/None. struct TabHit { TabHitKind kind = TabHitKind::None; int index = -1; @@ -115,12 +77,8 @@ struct TabHit { } }; -// Hit-tests a point (SWELL/LICE top-left client coords) against the strip laid out -// for `tabCount` tabs at `scrollOffset`. Chevrons take precedence over tabs at the -// strip ends (a click in the reserved chevron band is a scroll, never a tab), and a -// point outside the strip band, or in the track but not on any visible tab, is None. -// Half-open bounds match computeTabRects / the chevron bands so no pixel is claimed -// twice. The shell passes the SAME clamped scrollOffset it drew with. +// Hit-tests a point against the strip laid out for `tabCount` tabs at `scrollOffset`. Chevrons +// take precedence at the strip ends. Half-open bounds match computeTabRects. TabHit hitTestTabStrip(int px, int py, const TabStripRect& strip, int tabCount, const TabStripSpec& spec, int scrollOffset); diff --git a/src/core/ui/theme.cpp b/src/core/ui/theme.cpp index e80ee62..97d95f9 100644 --- a/src/core/ui/theme.cpp +++ b/src/core/ui/theme.cpp @@ -1,4 +1,4 @@ -// theme — pure implementation. See theme.h. NO REAPER / SWELL / LICE / vendor. +// theme — pure implementation. See theme.h. #include "core/ui/theme.h" @@ -10,59 +10,48 @@ namespace reasampler::ui { namespace { // =========================================================================== -// THE ONE DIRECTION CONSTANTS BLOCK (DS-2 revised: B "Neon Console" REAPER-grey -// neutrals + three-accent pastel system + C pastel spectral). -// -// This is the SINGLE POINT OF CHANGE. Every role color below is one of these -// constants; roleColor() is a pure switch over them. To re-pick the visual -// direction (§4: A Studio Rack / B Neon Console / C full spectral), edit THIS -// block — no shell, no other module, names a color. Values are locked against -// each WCAG floor (proven by test_theme.cpp): text/dim is lifted to the lightest -// grey that still clears AA 4.5:1 body on the greyest surface it draws on; each -// pastel accent is the softest tint that still clears the 3:1 indicator floor on -// bg/cell ("punch from the soft side" — DS-2 revised §2.1 grey re-read). +// THE ONE DIRECTION CONSTANTS BLOCK. Every role color below is one of these constants; +// roleColor() is a pure switch over them — this is the single point of change for the +// visual direction. Values are locked against each WCAG floor (proven by test_theme.cpp): +// text/dim is lifted to the lightest grey that still clears AA 4.5:1 body on the greyest +// surface it draws on; each pastel accent is the softest tint that still clears the 3:1 +// indicator floor on bg/cell ("punch from the soft side"). // =========================================================================== -// REAPER-theme mid-grey elevation stack (DS-2 revised — NOT near-black). Matches -// Daniel's REAPER theme so the dock reads as part of REAPER: base = window chrome -// grey, panel/cell one step lighter each. The elevation-ladder discipline is -// unchanged (base < panel < cell by a few %, micro-gradient + inner highlight/ -// shadow carry elevation, not hard borders); only the VALUES moved up into grey. +// REAPER-theme mid-grey elevation stack, matching Daniel's REAPER theme so the dock reads as +// part of REAPER: base = window chrome grey, panel/cell one step lighter each. Elevation-ladder +// discipline: base < panel < cell by a few %, micro-gradient + inner highlight/shadow carry +// elevation, not hard borders. constexpr KitColor kDirBgBase {43, 43, 43, 255}; // #2b2b2b — REAPER chrome grey constexpr KitColor kDirBgPanel {51, 51, 51, 255}; // #333333 — one step lighter constexpr KitColor kDirBgCell {58, 58, 58, 255}; // #3a3a3a — REAPER track bg constexpr KitColor kDirHairline {74, 74, 74, 255}; // #4a4a4a — subtle step above cell -// Text: REAPER body light-grey primary (#dcdcdc, clears ~8:1 on bg/cell) + a dimmer -// grey secondary. The greyer surfaces shrank the dim cushion (mid-grey-on-mid-grey -// is the classic AA failure): the spec-start #a0a0a0 lands ~4.35:1 on bg/cell, UNDER -// the AA 4.5 body floor — lifted to #a8a8a8 (~4.78:1 on bg/cell), the lightest grey -// that still reads dim while clearing AA 4.5 body on the greyest surface it draws -// body text on. Locked by test_theme.cpp. +// Text: REAPER body light-grey primary (#dcdcdc, clears ~8:1 on bg/cell) + a dimmer grey +// secondary. Mid-grey-on-mid-grey is the classic AA failure: the spec-start #a0a0a0 lands +// ~4.35:1 on bg/cell, under the AA 4.5 body floor — lifted to #a8a8a8 (~4.78:1), the lightest +// grey that still reads dim while clearing the floor. Locked by test_theme.cpp. constexpr KitColor kDirTextPrimary{220, 220, 220, 255}; // #dcdcdc constexpr KitColor kDirTextDim {168, 168, 168, 255}; // #a8a8a8 (lifted from #a0a0a0) -// The three-accent pastel system (DS-2 revised — replaces the single electric cyan). -// primary = pastel lime (the live/active/selected signal, the eye-magnet); secondary -// = pastel teal, tertiary = pastel purple (CATEGORICAL distinctions — a KIND, never -// intensity). accent/hot is a brighter tint OF the primary for hover/live/drag. On the -// greyer bg/cell the pastels clear the 3:1 indicator floor comfortably (primary ~7.6, -// secondary ~6.8, tertiary ~5.5) at the spec-start values, so no per-hue nudge was -// needed — the hues stay pastel lime/teal/purple. warn is a reserved red/amber for -// byte-deleting states only. +// Three-accent pastel system: primary = pastel lime (the live/active/selected signal); +// secondary = pastel teal, tertiary = pastel purple (CATEGORICAL distinctions — a KIND, never +// intensity). accent/hot is a brighter tint OF the primary for hover/live/drag. On bg/cell the +// pastels clear the 3:1 indicator floor comfortably at these values (primary ~7.6, secondary +// ~6.8, tertiary ~5.5), so no per-hue nudge was needed. warn is reserved for byte-deleting +// states only. constexpr KitColor kDirAccentPrimary {176, 224, 152, 255}; // #B0E098 — pastel lime constexpr KitColor kDirAccentSecondary{132, 214, 208, 255}; // #84D6D0 — pastel teal constexpr KitColor kDirAccentTertiary {194, 170, 232, 255}; // #C2AAE8 — pastel purple constexpr KitColor kDirAccentHot {200, 236, 178, 255}; // #C8ECB2 — lighter pastel lime constexpr KitColor kDirWarn {235, 120, 90, 255}; // #eb785a — destructive only -// Direction C pastel spectral ramp (DS-2 revised): a three-stop sweep through the -// accents — pastel lime (low) -> pastel teal (mid) -> pastel purple (high) — so the -// signature keyboard strip reads as an extension of the accent system, not a neon -// flourish. Endpoints/midpoint ARE the three accent constants (single source). -constexpr KitColor kDirSpectralLo = kDirAccentPrimary; // low notes: pastel lime -constexpr KitColor kDirSpectralMid = kDirAccentSecondary; // mid notes: pastel teal -constexpr KitColor kDirSpectralHi = kDirAccentTertiary; // high notes: pastel purple +// Spectral ramp: pastel lime (low) -> pastel teal (mid) -> pastel purple (high). Endpoints and +// midpoint ARE the three accent constants (single source), so the keyboard strip reads as an +// extension of the accent system. +constexpr KitColor kDirSpectralLo = kDirAccentPrimary; +constexpr KitColor kDirSpectralMid = kDirAccentSecondary; +constexpr KitColor kDirSpectralHi = kDirAccentTertiary; // --- state transform helpers ------------------------------------------------- @@ -70,8 +59,8 @@ std::uint8_t clamp8(int v) { return static_cast(v < 0 ? 0 : (v > 255 ? 255 : v)); } -// Linear blend from a toward b by t in [0, 1] (alpha carried from a — a state -// tint changes hue/brightness, not opacity; disabled handles alpha separately). +// Linear blend from a toward b by t in [0, 1] (alpha carried from a — a state tint changes +// hue/brightness, not opacity; disabled handles alpha separately). KitColor mix(const KitColor& a, const KitColor& b, double t) { return KitColor{ clamp8(static_cast(std::lround(a.r + (b.r - a.r) * t))), @@ -81,7 +70,6 @@ KitColor mix(const KitColor& a, const KitColor& b, double t) { }; } -// Scale RGB by factor (brightness up/down), alpha untouched. KitColor scale(const KitColor& c, double factor) { return KitColor{ clamp8(static_cast(std::lround(c.r * factor))), @@ -93,7 +81,6 @@ KitColor scale(const KitColor& c, double factor) { // Desaturate toward the color's own luminance-gray by amount in [0, 1]. KitColor desaturate(const KitColor& c, double amount) { - // 8-bit gray from the perceptual weights (same weighting family as luminance). const int gray = clamp8(static_cast( std::lround(0.2126 * c.r + 0.7152 * c.g + 0.0722 * c.b))); const KitColor g{static_cast(gray), @@ -132,25 +119,20 @@ KitColor roleColorState(Role role, InteractionState state) { case InteractionState::Rest: return base; case InteractionState::Hover: - // Lighten the surface toward the hot accent (~10%) — the "alive" cue. + // Lighten toward the hot accent (~10%) — the "alive" cue. return mix(base, roleColor(Role::AccentHot), 0.10); case InteractionState::Active: - // The selected/active layer carries the PRIMARY accent — "this is live" - // is always the primary hue (DS-2 revised: primary leads state; secondary/ - // tertiary are categorical, never intensity). + // "This is live" is always the primary hue — secondary/tertiary stay categorical. return roleColor(Role::AccentPrimary); case InteractionState::Pressed: - // The surface "pushes in": darken. - return scale(base, 0.82); + return scale(base, 0.82); // the surface "pushes in" case InteractionState::Dragging: - // A live-drag element reads as active-but-lighter (primary -> hot). return mix(roleColor(Role::AccentPrimary), roleColor(Role::AccentHot), 0.30); case InteractionState::Focus: - // Focus keeps the surface but is drawn with a text/primary ring by the - // shell; the fill nudges toward the primary accent so focus reads pre-ring. + // Focus keeps the surface; the shell draws a text/primary ring on top, and the + // fill nudges toward the primary accent so focus reads pre-ring. return mix(base, roleColor(Role::AccentPrimary), 0.08); case InteractionState::Disabled: { - // Desaturate and drop alpha to 40% (§3.3). KitColor d = desaturate(base, 0.6); d.a = static_cast(std::lround(base.a * 0.4)); return d; @@ -162,10 +144,8 @@ KitColor roleColorState(Role role, InteractionState state) { KitColor spectralColor(double t) { if (t < 0.0) t = 0.0; if (t > 1.0) t = 1.0; - // Three-stop pastel sweep anchored on the accent trio (DS-2 revised Direction C): - // lime (low) -> teal (mid, t=0.5) -> purple (high). A single Lo->Hi lerp would skip - // the teal midpoint and drift the ramp off the accent family; interpolate each half - // so the midpoint IS the secondary accent and every stop stays in the pastel band. + // Interpolate each half separately so the midpoint IS the secondary accent (a single + // Lo->Hi lerp would skip it and drift the ramp off the accent family). if (t <= 0.5) { return mix(kDirSpectralLo, kDirSpectralMid, t / 0.5); } diff --git a/src/core/ui/theme.h b/src/core/ui/theme.h index 0d6f5ba..caaeb2b 100644 --- a/src/core/ui/theme.h +++ b/src/core/ui/theme.h @@ -1,35 +1,21 @@ #pragma once -// theme — the REAPER-free, LICE-free palette + type-scale core of the shared drawing -// kit (Phase L, L1). This is the "one source of drawing" made testable at its root: a -// ROLE-based color model (bg/base, bg/panel, bg/cell, line/hairline, text/primary, -// text/dim, accent/primary, accent/secondary, accent/tertiary, accent/hot, warn), an -// INTERACTION-STATE model (rest/hover/active/pressed/dragging/focus/disabled), and the -// WCAG contrast math that lets a unit test prove every text-on-surface pair clears its -// floor ("punch to the floor, not past it"). +// theme — the palette + type-scale core of the shared drawing kit: a ROLE-based color model +// (bg/base, bg/panel, bg/cell, line/hairline, text/primary, text/dim, accent/primary, +// accent/secondary, accent/tertiary, accent/hot, warn), an interaction-state model +// (rest/hover/active/pressed/dragging/focus/disabled), and the WCAG contrast math that lets a +// unit test prove every text-on-surface pair clears its floor. // -// THE SINGLE POINT OF CHANGE (DS-2 revised): every role color is produced by roleColor() -// from ONE direction constants block (kDirection*, below) carrying the settled B (Neon -// Console) neutrals — now REAPER-theme mid-grey, not near-black — plus the three-accent -// pastel system (primary lime / secondary teal / tertiary purple) and the C pastel -// spectral ramp. Switching the visual direction is editing that block and nothing else — -// no shell hardcodes a color; the shell asks the theme by role. The spectral (Direction C) -// hue ramp lives here too (spectralColor) so the signature keyboard strip's L3 consumer -// derives its per-note hue from the same source (a pastel sweep anchored on the three -// accents: primary lime -> secondary teal -> tertiary purple). -// -// PURE MODULE: NO REAPER types, NO SWELL, NO LICE, NO vendor/ includes. Standard library -// only. Builds and unit-tests without REAPER. Mirror of mode_switch / bank_grid — the -// shell (draw_kit) turns a KitColor into a LICE_pixel at the boundary; the theme never -// names a LICE type. +// Every role color is produced by roleColor() from ONE direction constants block (theme.cpp) — +// the single point of change; no shell hardcodes a color, it asks by role. The spectral hue ramp +// (spectralColor) lives here too so the keyboard strip derives its per-note hue from the same +// source, anchored on the three accents (primary -> secondary -> tertiary). #include namespace reasampler::ui { -// A straight 8-bit-per-channel RGBA color, LICE-free. The draw shell converts this to a -// LICE_pixel via LICE_RGBA at the boundary (draw_kit); nothing here depends on LICE's -// packing. Deliberately NOT named "Color"/"RGBA" (both are common collision surfaces); -// "KitColor" scopes it to the kit. +// Straight 8-bit-per-channel RGBA, LICE-free; draw_kit converts to LICE_pixel at the boundary. +// Named "KitColor" (not "Color"/"RGBA") to avoid collision. struct KitColor { std::uint8_t r = 0; std::uint8_t g = 0; @@ -41,8 +27,7 @@ struct KitColor { } }; -// The structural palette roles (direction-independent — §2.1 of the design doc). The -// direction (B/C) sets the concrete hue behind each; the shell always asks by role. +// Structural palette roles, direction-independent — the shell always asks by role. enum class Role { BgBase, // window canvas BgPanel, // a raised region (list, waveform pane) @@ -50,69 +35,57 @@ enum class Role { LineHairline, // separators (used sparingly — elevation carries most separation) TextPrimary, // labels, values TextDim, // secondary / units - AccentPrimary, // the live / active / selected signal — where the punch lives (pastel lime) + AccentPrimary, // live / active / selected — where the punch lives (pastel lime) AccentSecondary,// categorical role A (pastel teal) — a distinct KIND, never intensity AccentTertiary,// categorical role B (pastel purple) — a distinct KIND, never intensity AccentHot, // hover / live / drag feedback (a brighter tint OF the primary accent) Warn, // clip / destructive (prune, delete) — reserved for byte-deleting states }; -// The interaction-state model every kit component honors (§3.3). A component draws its -// role surface transformed by its current state; stateShift() below is that transform. +// Interaction-state model every kit component honors; stateShift (roleColorState) is the +// role-surface transform for the current state. enum class InteractionState { Rest, Hover, - Active, // selected / active + Active, Pressed, Dragging, Focus, Disabled, }; -// Text size classes for the WCAG floor. "Large" text (>= ~18.66px, or >= ~14px bold) and -// UI-state indicators clear at 3:1; body text clears at 4.5:1 (WCAG 2.1 AA). The kit's -// four cached fonts map onto these: title -> Large, label/value -> Body, micro -> Body. +// Text size classes for the WCAG floor: "Large" (>= ~18.66px, or >= ~14px bold) and UI-state +// indicators clear at 3:1; body text clears at 4.5:1 (WCAG 2.1 AA). enum class TextClass { Body, // AA 4.5:1 Large, // AA-large 3:1 (also the floor for state indicators) }; -// The concrete color for a role, produced from the ONE direction constants block. This is -// the single choke point the "single point of change" guarantee rests on: the shell has -// no other way to obtain a palette color, so re-picking the direction is editing the -// kDirection* block this reads and nothing else. +// The concrete color for a role, from the one direction constants block — the single choke +// point re-picking the direction touches. KitColor roleColor(Role role); -// The color for a role under an interaction state — roleColor(role) transformed by the -// state (hover lightens toward accent/hot, pressed darkens, disabled desaturates + drops -// alpha, etc.). Surfaces use this so every component gets the whole state model for free. -// Rest returns roleColor(role) unchanged. +// roleColor(role) transformed by state (hover lightens toward accent/hot, pressed darkens, +// disabled desaturates + drops alpha, etc). Rest returns roleColor(role) unchanged. KitColor roleColorState(Role role, InteractionState state); -// Direction C's spectral hue ramp (DS-2 revised — a PASTEL sweep anchored on the three -// accents, not the old neon cool-blue -> hot-magenta): maps a normalized position t in -// [0, 1] (low note -> high note across the keyboard strip) to a color that runs -// accent/primary (pastel lime, low) -> accent/secondary (pastel teal, mid) -> -// accent/tertiary (pastel purple, high). The same three hues that mean "live / category A -// / category B" elsewhere are the endpoints and midpoint here, so the strip reads as an -// extension of the accent system, not a separate flourish. The signature keyboard-strip -// surface (an L3 consumer) derives each note/zone's hue from this ONE function so the -// spectrum is defined in the same place as the rest of the palette. t is clamped to [0, 1]. +// Spectral hue ramp for the keyboard strip: maps normalized position t in [0, 1] (low note -> +// high note) through accent/primary (low) -> accent/secondary (mid) -> accent/tertiary (high), +// so the strip reads as an extension of the accent system rather than a separate flourish. +// t is clamped to [0, 1]. KitColor spectralColor(double t); // --- WCAG contrast (the "punch" rule, made testable) -------------------------- -// -// The relative luminance of a color per WCAG 2.1 (sRGB linearization + the 0.2126/ -// 0.7152/0.0722 weighting). Alpha is ignored — contrast is a question about the opaque -// hues; a translucent overlay's effective color is the caller's to compose first. + +// Relative luminance per WCAG 2.1 (sRGB linearization + 0.2126/0.7152/0.0722 weighting). Alpha +// is ignored — a translucent overlay's effective color is the caller's to compose first. double relativeLuminance(const KitColor& c); -// The WCAG contrast ratio between two colors, in [1, 21]. Symmetric; order-independent. +// WCAG contrast ratio between two colors, in [1, 21]. Symmetric. double contrastRatio(const KitColor& a, const KitColor& b); -// The contrast floor a text class must clear: 4.5 for Body, 3.0 for Large. The test that -// proves the palette asserts contrastRatio(text, surface) >= textFloor(class) for every -// pair the kit actually draws. +// Contrast floor a text class must clear: 4.5 for Body, 3.0 for Large. test_theme.cpp asserts +// contrastRatio(text, surface) >= textFloor(class) for every pair the kit actually draws. double textFloor(TextClass cls); } // namespace reasampler::ui diff --git a/src/core/ui/tooltip.cpp b/src/core/ui/tooltip.cpp index 5f09110..52f34f7 100644 --- a/src/core/ui/tooltip.cpp +++ b/src/core/ui/tooltip.cpp @@ -1,4 +1,4 @@ -// tooltip — pure implementation. See tooltip.h. NO REAPER / SWELL / LICE / vendor. +// tooltip — pure implementation. See tooltip.h. #include "core/ui/tooltip.h" diff --git a/src/core/ui/tooltip.h b/src/core/ui/tooltip.h index 1682597..4e7ce55 100644 --- a/src/core/ui/tooltip.h +++ b/src/core/ui/tooltip.h @@ -1,22 +1,14 @@ #pragma once -// tooltip — the REAPER-free layout math + text helper behind the bank_panel's custom hover-delay -// tooltip (Phase L, L5, refinement 2). Button FACES stay short (the terse shortLabel); hovering a -// button for a short delay pops a small tooltip carrying the FULL action name with the -// "ReaSampler:" display prefix stripped. The tooltip is a custom LICE-kit draw (NOT the native -// Win32 / SWELL tooltip control) — chosen so it is uniform across platforms and consistent with -// the L1 kit (brief §tooltip mechanism). The DAW-bound parts (the hover timer, the LICE overlay -// draw, the kbd/action-name query) live in the shell; what is NOT DAW-bound — WHERE the tooltip -// box sits relative to its anchor button within the panel client, and stripping the display -// prefix — lives here, unit-tested outside the DAW. Mirror of prune_button / component_geometry. -// -// PURE MODULE: NO REAPER types, NO SWELL, NO LICE, NO vendor/ includes. Standard library only. +// tooltip — layout math + text helper behind the bank_panel's custom hover-delay tooltip. Button +// faces stay short; hovering pops a small tooltip with the full action name, "ReaSampler:" +// display prefix stripped. Custom LICE-kit draw, not the native Win32/SWELL tooltip control, for +// cross-platform uniformity with the rest of the kit. #include namespace reasampler::ui { -// The tooltip's box (top-left origin, SWELL/LICE convention). A zero-area rect means "do not -// draw" (degenerate inputs); the caller checks empty() before drawing. +// Zero-area means "do not draw" (degenerate inputs); caller checks empty() first. struct TooltipBox { int x = 0; int y = 0; @@ -30,10 +22,8 @@ struct TooltipBox { } }; -// Placement inputs, in pixels. -// * gap — vertical gap between the anchor button and the tooltip box. -// * padX/padY — horizontal / vertical text padding inside the box. -// * margin — minimum clearance kept from the client edges when clamping. +// gap: vertical gap between anchor button and tooltip. padX/padY: text padding inside the box. +// margin: minimum clearance from client edges when clamping. struct TooltipSpec { int gap = 4; int padX = 6; @@ -41,20 +31,15 @@ struct TooltipSpec { int margin = 2; }; -// Strips the action DISPLAY PREFIX from a full action name for the tooltip face. The registered -// gaccel name is composed as `prefix + phrase` (prefix from actionDisplayPrefix(), e.g. -// "ReaSampler: "); the tooltip shows only the phrase. If `fullName` does not start with -// `prefix`, it is returned unchanged (defensive — a name from an unexpected source still shows). -// An empty prefix returns fullName unchanged. +// Strips the action display prefix (e.g. "ReaSampler: ") from a full action name for the +// tooltip face. If fullName doesn't start with prefix, returned unchanged (defensive). Empty +// prefix returns fullName unchanged. std::string stripActionPrefix(const std::string& fullName, const std::string& prefix); -// Places a tooltip of pixel size (textW + 2*padX) x (textH + 2*padY) for the button rect -// (anchorX, anchorY, anchorW, anchorH), clamped inside the client rect (0,0,clientW,clientH). -// Preference: BELOW the anchor, horizontally centred on it. If it would clip the bottom edge, -// it flips ABOVE the anchor. It is then clamped horizontally (and vertically as a last resort) -// to stay within `margin` of the client edges. Returns an empty box when the text extent or the -// client is degenerate. `textW`/`textH` are the measured text extents (the shell measures with -// the kit font before calling). +// Places a tooltip of size (textW + 2*padX) x (textH + 2*padY) for the anchor button rect, +// clamped inside the client rect. Prefers BELOW the anchor, centered; flips ABOVE if it would +// clip the bottom edge, then clamps to stay within `margin` of the client edges. Empty when the +// text extent or client is degenerate. textW/textH are measured by the shell before calling. TooltipBox computeTooltip(int anchorX, int anchorY, int anchorW, int anchorH, int textW, int textH, int clientW, int clientH, const TooltipSpec& spec); diff --git a/src/core/util/clamp01.h b/src/core/util/clamp01.h index 0a141a0..46dac1c 100644 --- a/src/core/util/clamp01.h +++ b/src/core/util/clamp01.h @@ -1,11 +1,8 @@ #pragma once -// clamp01 — the ONE unit-interval clamp (Q-W1, T4-24). Replaces the per-module -// static copies (master_gain / param_slider / envelope_overlay / reasampler_editor). -// Deliberately the ternary form: comparisons with NaN are false, so a NaN input -// passes through unchanged rather than silently collapsing to a bound — the -// behavior of the majority of the retired copies. -// -// PURE: standard library only (not even that). +// clamp01 — the ONE unit-interval clamp, replacing several per-module static +// copies. Deliberately the ternary form: comparisons with NaN are false, so a NaN +// input passes through unchanged rather than silently collapsing to a bound — the +// behavior most of the retired copies already had. namespace reasampler::util { diff --git a/src/core/util/file_bytes.h b/src/core/util/file_bytes.h index 5214850..e699048 100644 --- a/src/core/util/file_bytes.h +++ b/src/core/util/file_bytes.h @@ -1,7 +1,6 @@ -// core/util/file_bytes — the ONE whole-file byte loader (Q-W1; audit T2-03). -// Pure standard library — NO REAPER, NO SWELL, NO VST3 — but it does blocking -// file I/O: NEVER call it on the audio thread (off-thread only, the same rule -// every prior hand-rolled copy carried). Linked by both artifacts. +// core/util/file_bytes — the ONE whole-file byte loader. Pure standard library — +// NO REAPER, NO SWELL, NO VST3 — but it does blocking file I/O: NEVER call it on +// the audio thread. Linked by both artifacts. #pragma once diff --git a/src/core/version/app_version.cpp b/src/core/version/app_version.cpp index 568f02d..9d1db36 100644 --- a/src/core/version/app_version.cpp +++ b/src/core/version/app_version.cpp @@ -1,10 +1,9 @@ -// app_version.cpp — implementation of the pure version-identity core (Phase V, V1 + V4). -// See app_version.h for the contract. The version STRING and the channel bit both come -// from version_generated.h (produced by CMake configure_file from the one -// REASAMPLER_VERSION variable + the REASAMPLER_CHANNEL flag) — this TU re-exports them and -// owns every pure derivation: the channel-qualified identity strings (V4) and the -// parse/compare/classify logic (V1). No #ifdef forks leak beyond this file; the shells -// consume the accessors below, so channel identity is one auditable definition. +// app_version.cpp — implementation of the pure version-identity core. See +// app_version.h for the contract. The version STRING and the channel bit both +// come from version_generated.h (CMake configure_file from REASAMPLER_VERSION + +// REASAMPLER_CHANNEL) — this TU re-exports them and owns every pure derivation. +// No #ifdef forks leak beyond this file; the shells consume the accessors +// below, so channel identity is one auditable definition. #include "core/version/app_version.h" @@ -15,9 +14,8 @@ namespace reasampler::version { namespace { -// The one channel predicate every derivation below branches on — the single point the -// configure_file'd bit enters the pure module. constexpr so the branches fold at compile -// time; the accessors still return by const ref for a stable shared instance. +// The one channel predicate every derivation below branches on. constexpr so +// the branches fold at compile time. constexpr bool kIsBeta = (REASAMPLER_CHANNEL_IS_BETA != 0); } // namespace @@ -26,9 +24,6 @@ Channel channel() { return kIsBeta ? Channel::Beta : Channel::Stable; } bool isBeta() { return kIsBeta; } const std::string& appVersion() { - // The user-visible render. Stable: EXACTLY the CMake string (leading zero and all). - // Beta: the same numeric string plus a plain "-beta" suffix (V2). Function-local - // static so callers share one authoritative instance. static const std::string kVersion = kIsBeta ? std::string(REASAMPLER_VERSION_STRING) + "-beta" : std::string(REASAMPLER_VERSION_STRING); @@ -36,32 +31,22 @@ const std::string& appVersion() { } const std::string& stampVersion() { - // The ext-state stamp value — the NUMERIC TRIPLE ONLY, IDENTICAL on both channels. - // No "-beta" suffix: it must parse as Stamped on read-back (a suffixed stamp classifies - // as Unknown), and stable's stamp stays byte-identical regardless of the channel build. - // The channel is carried by extStateNamespace(), never baked into the stamp. static const std::string kStamp = REASAMPLER_VERSION_STRING; return kStamp; } const std::string& extStateNamespace() { - // Stable "reasampler" is byte-identical to the pre-V4 build; beta is isolated. - // FOREVER-STABLE per channel. static const std::string kNs = kIsBeta ? "reasampler_beta" : "reasampler"; return kNs; } const std::string& commandIdPrefix() { - // Stable prefix is byte-identical to every shipped command id; beta is a distinct - // forever-family. FOREVER-STABLE per channel. static const std::string kPrefix = kIsBeta ? "CEREBELLUM_REASAMPLER_BETA_" : "CEREBELLUM_REASAMPLER_"; return kPrefix; } const std::string& actionDisplayPrefix() { - // Actions-list legibility: two channels must be distinguishable by name. Trailing - // space so callers append the action phrase directly. static const std::string kDisp = kIsBeta ? "ReaSampler beta: " : "ReaSampler: "; return kDisp; } @@ -79,26 +64,18 @@ const std::string& dockTitle() { } const std::string& dockIdent() { - // Persisted dock-position ident — FOREVER-STABLE per channel (changing it strands the - // saved dock slot). Beta qualified so the two panels do not fight over one slot. static const std::string kIdent = kIsBeta ? "reasampler_bank_panel_beta" : "reasampler_bank_panel"; return kIdent; } const std::string& vstOutputName() { - // The .vst3 module OUTPUT_NAME base — FOREVER-STABLE per channel. Stable is - // byte-identical to pre-S18 ("reasampler_9000"); beta is isolated so both install - // side-by-side without a filename collision. static const std::string kName = kIsBeta ? "reasampler_9000_beta" : "reasampler_9000"; return kName; } const std::string& vstPluginName() { - // The factory display name / editor title / embed label. Stable is byte-identical to - // pre-S18 ("ReaSampler 9000"); beta appends " beta" so the two channels are distinct - // plugins in the FX browser. static const std::string kName = kIsBeta ? "ReaSampler 9000 beta" : "ReaSampler 9000"; return kName; diff --git a/src/core/version/app_version.h b/src/core/version/app_version.h index 8ef85bf..ed40e42 100644 --- a/src/core/version/app_version.h +++ b/src/core/version/app_version.h @@ -1,51 +1,49 @@ #pragma once -// app_version — the REAPER-free version-identity core (Phase V, V1 + V4). The single -// source of truth for the version STRING lives in CMake (a `REASAMPLER_VERSION` -// variable threaded in via configure_file -> version_generated.h); this module -// re-exports it as the canonical constant and owns every pure operation on it: the -// exact-string render, the parse/compare arithmetic a within-channel forward -// migration will lean on, and the "which version wrote this project" result that -// persist reads back from ext state (absent stamp = pre-versioning, never an error). +// app_version — the REAPER-free version-identity core. The single source of +// truth for the version STRING lives in CMake (a `REASAMPLER_VERSION` variable +// threaded in via configure_file -> version_generated.h); this module re-exports +// it as the canonical constant and owns every pure operation on it: the +// exact-string render, the parse/compare arithmetic, and the "which version +// wrote this project" result persist reads back from ext state (absent stamp = +// pre-versioning, never an error). // -// V4 (beta-in-isolation) extends this module into the SINGLE SOURCE OF TRUTH FOR -// CHANNEL IDENTITY too. A compile-time flag (`-DREASAMPLER_CHANNEL=beta`, threaded -// through the same configure_file'd version_generated.h as REASAMPLER_CHANNEL_IS_BETA) -// selects stable (the default, absent-flag build — byte-for-byte today's identity) or -// a fully isolated beta build. Every channel-qualified identity string the shells -// register with REAPER — the display suffix, the ext-state namespace, the command-id -// prefix, the Actions-list name prefix, the binary/dock idents — is DERIVED HERE from -// the one channel bit, so no scattered #ifdef forks live across the translation units; -// the shells just consume these accessors. This keeps "what makes a beta a beta" one -// auditable definition and makes the channel-derived rendering unit-testable. +// This module is also the single source of truth for CHANNEL IDENTITY. A +// compile-time flag (`-DREASAMPLER_CHANNEL=beta`, threaded through +// version_generated.h as REASAMPLER_CHANNEL_IS_BETA) selects stable (the +// default, byte-for-byte today's identity) or a fully isolated beta build. +// Every channel-qualified identity string the shells register with REAPER — the +// display suffix, the ext-state namespace, the command-id prefix, the +// Actions-list name prefix, the binary/dock idents — is derived here from the +// one channel bit, so no scattered #ifdef forks live across translation units. // // PURE MODULE: NO REAPER types, NO SWELL, NO vendor/ includes. Standard library -// only. Builds and unit-tests without REAPER (mirror of bank_model / tail_control). +// only. // -// Leading-zero fidelity (V1, Daniel-fixed): the displayed/stamped string preserves the -// configured version EXACTLY as written — padded and unpadded versions are both -// legitimate (e.g. "0.9.8" and "0.9.80" are different versions; a "0.9.01" renders its -// zero-padded patch verbatim). That exactness is why the version STRING is the -// authoritative artifact (sourced verbatim from the one CMake variable), not a -// reconstruction from numeric components — CMake's `project(VERSION)` may normalize a -// numeric patch field, so we never round-trip the string through integers to render it. -// Guarded against reconstruct-from-components regressions in app_version.cpp by -// app_version_padding_tests (the padded canary build). The canary does NOT guard -// against the CMakeLists.txt source-of-truth line being changed to a CMake variable -// derivation — that case is guarded by the comment block at the top of CMakeLists.txt. +// Leading-zero fidelity (Daniel-fixed): the displayed/stamped string preserves +// the configured version EXACTLY as written — padded and unpadded versions are +// both legitimate (e.g. "0.9.8" and "0.9.80" are different versions; "0.9.01" +// renders its zero-padded patch verbatim). That's why the version STRING is the +// authoritative artifact, not a reconstruction from numeric components — CMake's +// `project(VERSION)` may normalize a numeric patch field, so we never round-trip +// the string through integers to render it. Guarded against +// reconstruct-from-components regressions by app_version_padding_tests (the +// padded canary build); it does NOT guard against the CMakeLists.txt +// source-of-truth line itself changing — that's guarded by the comment block at +// the top of CMakeLists.txt. #include #include namespace reasampler::version { -// --- Channel identity (V4, beta-in-isolation) --------------------------------------- +// --- Channel identity (beta-in-isolation) -------------------------------------------- // -// The build channel, fixed at compile time by REASAMPLER_CHANNEL_IS_BETA (0 = stable, -// the default absent-flag build; 1 = beta, from -DREASAMPLER_CHANNEL=beta). Stable is -// today's build with byte-identical identity in EVERY string below — any divergence on -// the stable channel is a defect. Beta forks every identity so a beta binary coexists -// with stable in one REAPER (both are dlopen'd at startup) without colliding on project -// ext-state, keybindings, or any REAPER-global registration. +// The build channel, fixed at compile time by REASAMPLER_CHANNEL_IS_BETA (0 = +// stable, the default; 1 = beta, from -DREASAMPLER_CHANNEL=beta). Stable is +// byte-identical to today's build in every string below — any divergence there +// is a defect. Beta forks every identity so a beta binary coexists with stable +// in one REAPER without colliding on project ext-state, keybindings, or any +// REAPER-global registration. enum class Channel { Stable, Beta }; // The channel this build was compiled for. Constant per binary. @@ -54,87 +52,75 @@ Channel channel(); // True on the beta build only. Convenience over channel() == Channel::Beta. bool isBeta(); -// The user-visible version render. Stable: EXACTLY the configured CMake string. Beta: -// that string plus a plain "-beta" suffix — a plain suffix, NOT a git-describe -// decoration (V2, Daniel-fixed). This is what the show-version action and the -// bank-panel readout display. It is NOT the ext-state stamp value (see stampVersion). +// The user-visible version render. Stable: exactly the configured CMake string. +// Beta: that string plus a plain "-beta" suffix (not a git-describe decoration). +// What the show-version action and bank-panel readout display — NOT the +// ext-state stamp value (see stampVersion). const std::string& appVersion(); -// The ext-state STAMP value — the writing-version recorded into a saved project. This is -// the NUMERIC TRIPLE ONLY (the configured string, no channel suffix) on BOTH channels: -// it deliberately carries NO channel suffix, so (a) parseVersion classifies it as -// Stamped when its own channel reads it back (a "-beta"-suffixed stamp would classify -// as Unknown — the V4 stamp-classifiability requirement), and (b) stable's stamp value -// is byte-identical regardless of the channel build. The channel is carried by the -// ISOLATED namespace (see extStateNamespace), never baked into the stamp. Distinct from -// appVersion() precisely so the display can say "-beta" while the stamp stays -// classifiable and stable-identical. +// The ext-state STAMP value — the writing-version recorded into a saved +// project. The numeric triple only, no channel suffix, on BOTH channels: (a) so +// parseVersion classifies it as Stamped when its own channel reads it back (a +// "-beta"-suffixed stamp would classify as Unknown), and (b) so stable's stamp +// is byte-identical regardless of channel build. The channel is carried by the +// isolated namespace (see extStateNamespace), never baked into the stamp. const std::string& stampVersion(); -// The project ext-state namespace this channel reads and writes. Stable: "reasampler" -// (byte-identical to the pre-V4 build). Beta: "reasampler_beta". FOREVER-STABLE per -// channel once shipped — changing either orphans every already-saved project's state. +// The project ext-state namespace this channel reads and writes. Stable: +// "reasampler". Beta: "reasampler_beta". FOREVER-STABLE per channel once +// shipped — changing either orphans every already-saved project's state. // -// ISOLATION SEMANTICS (V4, accepted — not a bug): a channel reads/writes ONLY its own -// namespace. A project saved by stable shows empty/default ReaSampler state when opened -// in beta, and vice versa. There is NO cross-namespace read, migration, or fallback in -// this wave — that isolation is the safety property (a beta can never read or rewrite a -// stable project's bank/view/tail state). +// ISOLATION (accepted, not a bug): a channel reads/writes ONLY its own +// namespace — a project saved by stable shows empty/default state when opened +// in beta, and vice versa. No cross-namespace read, migration, or fallback: a +// beta can never read or rewrite a stable project's bank/view/tail state. const std::string& extStateNamespace(); -// The FOREVER-STABLE command-id prefix every bindable action mints its id from. Stable: -// "CEREBELLUM_REASAMPLER_" (byte-identical to the shipped ids). Beta: -// "CEREBELLUM_REASAMPLER_BETA_", a DISTINCT forever-family so beta and stable actions -// never collide in REAPER's one Actions list and their keybindings stay independent. -// Callers concatenate their per-action suffix onto this (e.g. prefix + "CAPTURE_TRACK"). -// PERMANENT once a beta ships — mark any minted id FOREVER-STABLE like stable's. +// The FOREVER-STABLE command-id prefix every bindable action mints its id from. +// Stable: "CEREBELLUM_REASAMPLER_". Beta: "CEREBELLUM_REASAMPLER_BETA_", a +// distinct forever-family so beta and stable actions never collide in REAPER's +// one Actions list. Callers concatenate their per-action suffix onto this. const std::string& commandIdPrefix(); -// The Actions-list DISPLAY-NAME prefix, so two coexisting channels are distinguishable in -// REAPER's Actions list. Stable: "ReaSampler: " (unchanged). Beta: "ReaSampler beta: ". -// Callers build a gaccel desc as actionDisplayPrefix() + "capture selected track", etc. +// The Actions-list display-name prefix, so two coexisting channels are +// distinguishable. Stable: "ReaSampler: ". Beta: "ReaSampler beta: ". Callers +// build a gaccel desc as actionDisplayPrefix() + "capture selected track", etc. const std::string& actionDisplayPrefix(); // The binary/module OUTPUT NAME base. Stable: "reaper_reasampler". Beta: -// "reaper_reasampler_beta". Mirrors the CMake OUTPUT_NAME (which is the authoritative -// artifact name); exposed here for any in-binary self-identification. REAPER dlopen's -// any reaper_* module, so both channels load side-by-side. +// "reaper_reasampler_beta". Mirrors the CMake OUTPUT_NAME. REAPER dlopen's any +// reaper_* module, so both channels load side-by-side. const std::string& binaryName(); -// The docked bank-panel identity strings, channel-qualified so the two panels are -// distinguishable and do not fight over one persisted dock slot (a REAPER-global -// collision surface — DockWindowAddEx's identstr keys the saved dock position). -// dockTitle() — the visible dock tab title. Stable: "ReaSampler Bank". -// Beta: "ReaSampler Bank beta". -// dockIdent() — the persisted dock-position ident. Stable: "reasampler_bank_panel". -// Beta: "reasampler_bank_panel_beta". FOREVER-STABLE per channel. +// The docked bank-panel identity strings, channel-qualified so the two panels +// don't fight over one persisted dock slot (DockWindowAddEx's identstr keys the +// saved dock position). +// dockTitle() — visible dock tab title. Stable: "ReaSampler Bank". +// dockIdent() — persisted dock-position ident. Stable: "reasampler_bank_panel". +// FOREVER-STABLE per channel. const std::string& dockTitle(); const std::string& dockIdent(); -// --- VST3 instrument identity (S18, beta-in-isolation) ------------------------------ +// --- VST3 instrument identity (beta-in-isolation) ------------------------------ // -// The ReaSampler 9000 VST3 instrument forks its plugin identity per channel exactly as the -// extension forks its binary/dock idents above — one channel per binary, all derived from -// the ONE channel bit here, so the VST shell carries no #ifdef fork. These are the VST's -// analogues of binaryName()/dockTitle(): the on-disk module name and the human-facing name. +// The ReaSampler 9000 VST3 instrument forks its plugin identity per channel +// exactly as the extension forks its binary/dock idents above. These are the +// VST's analogues of binaryName()/dockTitle(): the on-disk module name and the +// human-facing name. // -// vstOutputName() — the CMake OUTPUT_NAME base for the .vst3 module. Stable: -// "reasampler_9000" (byte-identical to pre-S18). Beta: -// "reasampler_9000_beta". Mirrors the CMake target's OUTPUT_NAME (the -// authoritative artifact name); exposed here so the one derivation lives -// in this module. FOREVER-STABLE per channel — the on-disk filename a -// REAPER project's saved instance path may reference. -// vstPluginName() — the factory display name (FX browser), editor title band, and S6 -// embed-strip label. Stable: "ReaSampler 9000". Beta: -// "ReaSampler 9000 beta". Sourced from here, never a literal in -// reasampler_vst.h / vst_entry.cpp / the editor / the embed strip. +// vstOutputName() — CMake OUTPUT_NAME base for the .vst3 module. Stable: +// "reasampler_9000". FOREVER-STABLE per channel — the +// on-disk filename a saved project's instance may reference. +// vstPluginName() — factory display name (FX browser), editor title band, and +// embed-strip label. Stable: "ReaSampler 9000". Sourced from +// here, never a literal in reasampler_vst.h / vst_entry.cpp / +// the editor / the embed strip. // -// NOTE: the VST3 CLASS UID is NOT here — a UID is not a string derivation but a compile-time -// FUID/INLINE_UID constant the factory needs in brace-init form; it lives in reasampler_vst.h, -// channel-selected by the same REASAMPLER_CHANNEL_IS_BETA bit. This module owns the string -// identity; reasampler_vst.h owns the binary UID identity. The version display the factory -// stamps into PClassInfo2 reuses appVersion() (it already renders "-beta" on beta) — no -// separate VST version accessor. +// The VST3 CLASS UID is NOT here — it's a compile-time FUID/INLINE_UID constant +// the factory needs in brace-init form; it lives in reasampler_vst.h, +// channel-selected by the same bit. This module owns the string identity; +// reasampler_vst.h owns the binary UID identity. The factory's PClassInfo2 +// version display reuses appVersion() — no separate VST version accessor. const std::string& vstOutputName(); const std::string& vstPluginName(); @@ -156,10 +142,10 @@ const std::string& vstPluginName(); std::string channelCommandId(const std::string& suffix); std::string channelActionName(const std::string& phrase); -// A parsed semver triple. Kept minimal — major.minor.patch as integers, for ORDERING -// only. It deliberately does NOT round-trip back to the display string (the leading -// zero is a rendering concern owned by the authoritative string, not reconstructable -// from the integer patch). parseVersion returns nullopt on malformed input. +// A parsed semver triple. Kept minimal — major.minor.patch as integers, for +// ordering only. Deliberately does NOT round-trip back to the display string +// (the leading zero is a rendering concern, not reconstructable from the +// integer patch). struct Version { int major = 0; int minor = 0; diff --git a/src/core/view/guid_diff.cpp b/src/core/view/guid_diff.cpp index 303f94e..ef03f9e 100644 --- a/src/core/view/guid_diff.cpp +++ b/src/core/view/guid_diff.cpp @@ -1,5 +1,4 @@ -// guid_diff implementation — pure set arithmetic for new-content detection. See -// guid_diff.h. No REAPER, no SWELL — std only. +// See guid_diff.h. #include "core/view/guid_diff.h" @@ -10,8 +9,6 @@ namespace reasampler::view { std::vector newGuids(const std::set& previous, const std::set& current) { std::vector added; - // current \ previous. std::set iterates ascending, so set_difference yields a - // deterministic order without a separate sort. for (const std::string& g : current) { if (g.empty()) continue; // never tag a GUID-read failure if (previous.count(g) == 0) added.push_back(g); @@ -21,24 +18,21 @@ std::vector newGuids(const std::set& previous, std::vector GuidBaseline::observe(const std::set& current) { if (!primed_) { - // First poll after open/reset: establish the baseline, report nothing new so - // pre-existing content is NOT auto-tagged (it defaults to Arrange). baseline_ = current; primed_ = true; return {}; } std::vector added = newGuids(baseline_, current); - // Advance the baseline to the full current set. Using `current` (not baseline_ ∪ - // added) means a DELETED GUID drops out of the baseline too, so if REAPER later - // reuses that GUID for genuinely new content it is detected again — the baseline - // tracks the live set exactly, not a monotonic union. + // Assign `current`, not baseline_ ∪ added: a deleted GUID drops out of the + // baseline, so a later reused GUID is detected again rather than looking + // pre-existing. baseline_ = current; return added; } void GuidBaseline::reset() { baseline_.clear(); - primed_ = false; // next observe() re-baselines (first-poll guard re-armed) + primed_ = false; } } // namespace reasampler::view diff --git a/src/core/view/guid_diff.h b/src/core/view/guid_diff.h index 5026ec2..f7bfbd2 100644 --- a/src/core/view/guid_diff.h +++ b/src/core/view/guid_diff.h @@ -1,17 +1,6 @@ #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. +// Pure, REAPER-free new-content detection: which GUIDs appeared since the last +// poll. See src/core/view/CLAUDE.md for the module contract. #include #include @@ -19,44 +8,25 @@ namespace reasampler::view { -// 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). +// GUIDs in `current` but not `previous`, ascending order; empty GUIDs ignored. std::vector newGuids(const std::set& previous, const std::set& current); -// Tracks the live GUID set across polls for ONE project, implementing the -// first-poll-after-open guard: the first observation after a (re)start establishes a -// BASELINE and reports NOTHING new, so pre-existing content stays at its default -// (Arrange) rather than being mass-tagged. Every subsequent observe() returns only the -// GUIDs created since the prior observe(). -// -// Project switches are handled by reset(): the shell detects a project change (the -// active ReaProject* / project GUID changed) and calls reset() so the next observe() -// re-baselines against the newly-opened project instead of diffing across two -// unrelated projects (which would spuriously "detect" the entire new project as new -// content, or miss content because a same-GUID collision looked pre-existing). +// Tracks the live GUID set across polls for one project. 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. + // First call after construction/reset() establishes the baseline and + // returns {}; later calls return GUIDs added since the prior call. std::vector observe(const std::set& current); - // Re-arms the first-poll guard: the next observe() re-baselines and reports - // nothing new. Called on a project switch so detection never diffs across - // projects. + // Re-arms the first-poll guard on a detected project switch. void reset(); - // True until the first observe() after construction/reset — exposed for the shell - // to reason about (and for tests) about whether a baseline is established yet. bool primed() const { return primed_; } private: std::set baseline_; - bool primed_ = false; // false ⇒ next observe() sets the baseline + bool primed_ = false; }; } // namespace reasampler::view diff --git a/src/core/view/lane_keys.cpp b/src/core/view/lane_keys.cpp index b003925..5b660f4 100644 --- a/src/core/view/lane_keys.cpp +++ b/src/core/view/lane_keys.cpp @@ -1,4 +1,4 @@ -// lane_keys implementation — pure string convention, no REAPER. See lane_keys.h. +// See lane_keys.h. #include "core/view/lane_keys.h" @@ -7,7 +7,6 @@ namespace reasampler::view { 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; @@ -19,11 +18,10 @@ bool isManagedLaneName(const std::string& laneName) { } std::optional managedLaneKey(const std::string& laneName) { - if (!hasManagedPrefix(laneName)) return std::nullopt; // manual/unnamed ⇒ no key - // The durable name IS the key (stable across ordinal renumber). Keeping the full - // prefixed name — rather than stripping to the mode id — means the key is globally - // unambiguous and the ownership index's mode field remains the single source of - // truth for which mode owns the lane. + if (!hasManagedPrefix(laneName)) return std::nullopt; + // Keep the full prefixed name as the key (not just the mode id) so it stays + // globally unambiguous; the ownership index's mode field is the sole + // source of truth for which mode owns the lane. return laneName; } @@ -32,19 +30,14 @@ std::string laneNameForMode(const std::string& modeId) { } std::optional modeIdFromLaneName(const std::string& laneName) { - if (!hasManagedPrefix(laneName)) return std::nullopt; // manual/unnamed ⇒ no mode + if (!hasManagedPrefix(laneName)) return std::nullopt; const std::size_t n = std::strlen(kManagedLanePrefix); - if (laneName.size() == n) return std::nullopt; // prefix only, no mode suffix (illegal) + if (laneName.size() == n) return std::nullopt; // prefix only, no mode suffix 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); } diff --git a/src/core/view/lane_keys.h b/src/core/view/lane_keys.h index 36cb133..c798899 100644 --- a/src/core/view/lane_keys.h +++ b/src/core/view/lane_keys.h @@ -1,85 +1,32 @@ #pragma once -// lane_keys — the pure, REAPER-free convention that maps a REAPER fixed lane's -// durable NAME (P_LANENAME:n) to the opaque lane-key the pure view_mode_model uses, -// and the managed/manual heuristic that rides on it. -// -// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL. std only. -// Unit-tested outside the DAW. The shell (view.cpp) reads each lane's P_LANENAME:n -// string from REAPER and asks this module whether the lane is tool-managed and what -// its stable lane-key is; the shell never re-derives the prefix rule itself. -// -// -- Design point #2 (lane-identity robustness) resolution -------------------- -// -// REAPER exposes no durable per-lane GUID. The only lane identity is the ordinal -// I_FIXEDLANE, which REAPER RENUMBERS when lanes are reordered or deleted — so keying -// the ownership index by raw ordinal would silently corrupt managed/manual ownership -// on any reorder. REAPER DOES expose a writable, durable lane NAME (P_LANENAME:n) that -// travels with the lane across renumber. So the tool names each lane it mints with a -// stable, prefixed identity ("reasampler:") and keys the ownership index by that -// NAME, not the ordinal. On each apply the shell walks the track's lanes by current -// ordinal, reads each name, and reconciles ordinal<->laneKey — so a C_LANEPLAYS:N -// write always targets the lane's CURRENT ordinal for a given durable key even after a -// reorder. A lane WITHOUT the prefix was not minted by the tool: it is manual and -// off-limits (the fixed-lane analog of "never touch mute/solo"). -// -// -- Design point #1 (manual-lane exemption) resolution ----------------------- -// -// The SAME prefix rule is the manual/managed heuristic for auto-tag: an item on a lane -// whose name lacks the "reasampler:" prefix is on a manual lane and is EXEMPT from -// auto-tag. isManagedLaneName is the single predicate both the toggle-apply path and -// the new-content detection path consult, so the boundary is defined in one place and -// unit-tested. +// Managed/manual fixed-lane convention: maps a lane's durable P_LANENAME to the +// opaque lane-key view_mode_model keys by. See src/core/view/CLAUDE.md (Gotchas): +// lane identity must ride the durable name, never the raw I_FIXEDLANE ordinal, +// or a reorder silently corrupts managed/manual ownership. #include #include namespace reasampler::view { -// 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. +// Prefix stamped on every lane name the tool mints. Stable-forever like an +// action-id string — changing it strands ownership of every already-minted lane. 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). +// A managed lane's key is its full durable name; nullopt for manual/unnamed. std::optional managedLaneKey(const std::string& laneName); -// The lane NAME the tool mints for the lane owned by `modeId` (kManagedLanePrefix + -// modeId). The inverse of managedLaneKey for a managed lane: managedLaneKey( -// laneNameForMode(m)) == kManagedLanePrefix + m. Exposed for the Wave-3 lane-minting -// path and for tests; the apply path in this wave only READS names, but the round-trip -// contract is asserted here so minting and reading cannot drift. +// Inverse pair: managedLaneKey(laneNameForMode(m)) == kManagedLanePrefix + m; +// modeIdFromLaneName(laneNameForMode(m)) == m. std::string laneNameForMode(const std::string& modeId); - -// The owning mode id encoded in a managed lane NAME — the suffix after the managed -// prefix. std::nullopt for a manual/unnamed lane (no managed prefix) or a name that is -// EXACTLY the prefix with no mode suffix (illegal — a managed lane always names a mode). -// The exact inverse of laneNameForMode: modeIdFromLaneName(laneNameForMode(m)) == m. -// Used by the load-time reconcile to recover managed ownership from REAPER's durable -// lane name (the source of truth for identity across sessions — design point #2). std::optional modeIdFromLaneName(const std::string& laneName); -// True iff an item on a fixed-lane track with the given lane name is on a MANUAL lane -// (i.e. exempt from auto-tag). The two inputs are: -// isFixedLaneTrack — whether the item's track has I_FREEMODE==2. On a normal -// (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. +// Single predicate governing both which lanes the apply path may drive and +// which items are exempt from auto-tag. No manual-lane concept on a non-fixed- +// lane track (returns false); on a fixed-lane track, any unprefixed name — +// including REAPER's default empty lane — is manual. bool isOnManualLane(bool isFixedLaneTrack, const std::string& laneName); } // namespace reasampler::view diff --git a/src/core/view/mode_switch.cpp b/src/core/view/mode_switch.cpp index 6bc8c57..eb23895 100644 --- a/src/core/view/mode_switch.cpp +++ b/src/core/view/mode_switch.cpp @@ -1,4 +1,4 @@ -// mode_switch — pure implementation. See mode_switch.h. NO REAPER / SWELL / vendor. +// See mode_switch.h. #include "core/view/mode_switch.h" @@ -8,11 +8,8 @@ namespace reasampler::view { namespace { -// The left edge of segment i in a header of the given x-origin and width divided -// into `count` segments. Boundary i is x + (i * width) / count, so segment i spans -// [edge(i), edge(i+1)). Because every boundary is derived from the same formula, -// consecutive segments share an exact edge (no gap, no overlap) and edge(count) -// == x + width precisely. count assumed >= 1 by callers. +// Left edge of segment i; segment i spans [edge(i), edge(i+1)). count assumed +// >= 1 by callers. int segmentEdge(int x, int width, int i, int count) { return x + (i * width) / count; } @@ -31,7 +28,7 @@ std::vector computeSegmentRects(const HeaderRect& header, SegmentRect r; r.x = left; r.y = header.y; - r.width = right - left; // absorbs rounding; adjacent segments abut exactly + r.width = right - left; r.height = header.height; rects.push_back(r); } @@ -41,23 +38,15 @@ std::vector computeSegmentRects(const HeaderRect& header, int hitTestSegment(int px, int py, const HeaderRect& header, int segmentCount) { if (segmentCount <= 0 || header.width <= 0 || header.height <= 0) return -1; - // Reject anything outside the header band first (half-open bounds match the - // segment rects). Below the header is where the grid lives — the panel falls - // through to grid handling on a -1. if (px < header.x || px >= header.x + header.width || py < header.y || py >= header.y + header.height) return -1; - // Inside the band: find the segment whose [edge(i), edge(i+1)) contains px. - // Linear over N (N is tiny — one per mode); mirrors the boundary formula so the - // hit matches the drawn segment exactly. for (int i = 0; i < segmentCount; ++i) { const int left = segmentEdge(header.x, header.width, i, segmentCount); const int right = segmentEdge(header.x, header.width, i + 1, segmentCount); if (px >= left && px < right) return i; } - // Guard: px == header.x + header.width would fail the < above but was already - // excluded by the band check. Any residual falls to -1 (defensive, unreachable). return -1; } diff --git a/src/core/view/mode_switch.h b/src/core/view/mode_switch.h index 0b85942..c388d6c 100644 --- a/src/core/view/mode_switch.h +++ b/src/core/view/mode_switch.h @@ -1,49 +1,23 @@ #pragma once #include "core/ui/rect.h" -// mode_switch — the REAPER-free layout math behind the bank_panel's Design-View -// mode switch (Phase D, Wave 4 — D5). A segmented control `[ Arrange | Design ]` -// (N-mode general, one segment per registered mode) drawn in a fixed-height header -// strip at the top of the docked panel. The panel shell (shell/panel/) owns the -// SWELL window, LICE drawing, and the live ViewModeModel read + mode activation — -// all REAPER-bound, DAW-verified. What is NOT DAW-bound — how N segments tile a -// header rectangle, and which segment a click lands in — lives here so it is -// unit-tested outside the DAW (CLAUDE.md §load-bearing split). Mirror of bank_grid. -// -// PURE MODULE: NO REAPER types, NO SWELL, NO vendor/ includes. Standard library -// only. Builds and unit-tests without REAPER. +// Pure segment layout + hit-test for the bank_panel's Design-View mode switch. +// Mirror of bank_grid. See src/core/view/CLAUDE.md. #include namespace reasampler::view { -// The header strip the switch is drawn into, top-left origin (SWELL/LICE -// convention). (x, y) is the top-left corner; width/height are the strip extents. -// The panel reserves this at the top of its client area and offsets the grid below. -using HeaderRect = ui::Rect; // Q-W1: the shared concrete ui::Rect (core/ui/rect.h), role-aliased +using HeaderRect = ui::Rect; +using SegmentRect = ui::Rect; -// One segment's pixel rectangle within the header, top-left origin. These are the -// draw bounds for one mode's button; the panel draws the mode's display name inside -// it and lights it when it is the active mode. -using SegmentRect = ui::Rect; // Q-W1: the shared concrete ui::Rect (core/ui/rect.h), role-aliased - -// Divides `header` into `segmentCount` equal segments left-to-right, in the caller's -// order (the panel passes modes in ordinal order). Returns exactly segmentCount -// rects. The division tiles the header EXACTLY: each segment's left edge is -// header.x + (i * width) / segmentCount, so integer rounding is absorbed at the -// boundaries — segments abut with no gap and no overlap, and the last segment -// reaches header.x + header.width precisely (individual widths may differ by one -// pixel when width does not divide evenly). Each segment inherits the header's full -// y/height. segmentCount <= 0 or a non-positive header width returns empty. +// Divides `header` into `segmentCount` equal segments left-to-right. Boundaries +// use header.x + (i * width) / segmentCount so segments abut exactly despite +// integer rounding. segmentCount <= 0 or non-positive width returns empty. std::vector computeSegmentRects(const HeaderRect& header, int segmentCount); -// Hit-tests a point (SWELL/LICE top-left client coords) against the segmented -// control laid out in `header` with `segmentCount` segments. Returns the index of -// the segment containing the point, or -1 for a miss: a point outside the header -// bounds entirely (including below it, where the grid lives), or when segmentCount -// <= 0. Half-open bounds [x, x+width) x [y, y+height) match computeSegmentRects, so -// adjacent segments never both claim a pixel and the point maps to the same segment -// the panel drew there. +// Segment index containing (px, py), or -1 for a miss (outside header bounds, +// or segmentCount <= 0). Half-open bounds match computeSegmentRects. int hitTestSegment(int px, int py, const HeaderRect& header, int segmentCount); } // namespace reasampler::view diff --git a/src/core/view/view_mode_model.cpp b/src/core/view/view_mode_model.cpp index f2cfbfc..fd6ab92 100644 --- a/src/core/view/view_mode_model.cpp +++ b/src/core/view/view_mode_model.cpp @@ -6,35 +6,16 @@ #include #include "core/json/json.h" -#include "core/view/lane_keys.h" // laneNameForMode — the ONE durable managed-lane-key convention - -// view_mode_model implementation. -// -// JSON rides on the shared core/json lexical layer (Q-W1), mirroring bank_model. -// A compact writer -// plus a recursive-descent parser covers the field set: the mode registry, the -// GUID-keyed membership map, per-track snapshots (with a variable-length per-FX -// offline vector), and the active mode. Ints are emitted plainly; strings are -// escaped identically to bank_model so control chars and unicode survive. +#include "core/view/lane_keys.h" // laneNameForMode namespace reasampler { -// Q-W1 interim: laneNameForMode lives in reasampler::view now; this god module -// re-namespaces in its own split wave. using view::laneNameForMode; -// --------------------------------------------------------------------------- -// equality -// --------------------------------------------------------------------------- - bool Mode::operator==(const Mode& o) const { return id == o.id && displayName == o.displayName && ordinal == o.ordinal; } -// --------------------------------------------------------------------------- -// ModeRegistry -// --------------------------------------------------------------------------- - ModeRegistry::ModeRegistry() { modes_.push_back(Mode{kArrangeModeId, "Arrange", 0}); modes_.push_back(Mode{kDesignModeId, "Design", 1}); @@ -44,8 +25,6 @@ bool ModeRegistry::add(const Mode& mode) { if (mode.id.empty()) return false; if (query(mode.id) != nullptr) return false; // ids are unique modes_.push_back(mode); - // Keep ordinal order stable; std::stable_sort so equal ordinals keep insertion - // order (the tie-break documented in the header). std::stable_sort(modes_.begin(), modes_.end(), [](const Mode& a, const Mode& b) { return a.ordinal < b.ordinal; }); return true; @@ -57,10 +36,6 @@ const Mode* ModeRegistry::query(const std::string& id) const { return nullptr; } -// --------------------------------------------------------------------------- -// MembershipIndex -// --------------------------------------------------------------------------- - bool MembershipIndex::tag(const std::string& guid, const std::string& modeId) { if (guid.empty() || modeId.empty()) return false; Membership& m = entries_[guid]; @@ -95,10 +70,6 @@ std::set MembershipIndex::modesOf(const std::string& guid) const { return m ? m->modeIds : std::set{}; } -// --------------------------------------------------------------------------- -// LaneOwnershipIndex -// --------------------------------------------------------------------------- - bool LaneOwnershipIndex::setManaged(const std::string& trackGuid, const std::string& laneKey, const std::string& modeId) { if (trackGuid.empty() || laneKey.empty() || modeId.empty()) return false; @@ -123,24 +94,12 @@ const LaneOwnership* LaneOwnershipIndex::query(const std::string& trackGuid, } int laneModeState(const std::string& managedMode, const std::string& activeMode) { - // The active mode's lane plays exclusively; every other managed lane is silenced - // 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. + // Assumes at most one managed lane per (track, mode) — planToggle asserts + // this in debug builds; two lanes claiming the same mode would both be + // told to play exclusively, which REAPER can't honor coherently. return managedMode == activeMode ? kLanePlaysExclusive : kLaneSilent; } -// --------------------------------------------------------------------------- -// auto-tag decision -// --------------------------------------------------------------------------- - std::vector autoTagNewContent(const std::vector& newTrackGuids, const std::vector& newItems, const std::string& activeMode) { @@ -153,14 +112,9 @@ std::vector autoTagNewContent(const std::vector& newTrackG } for (const auto& item : newItems) { if (item.guid.empty()) continue; - if (item.onManualLane) continue; // manual-lane content is off-limits to auto-tag + if (item.onManualLane) continue; - // ADOPTION (strand guard): a new item on a track whose PRE-EXISTING content - // resolves to exactly one mode adopts THAT mode, so a drop onto a track already - // showing content never pushes it multi-mode and never triggers a lane split that - // would silence the pre-existing, previously-visible items. A track with no prior - // content (empty trackModes) or one already carrying a deliberate multi-mode split - // (>1) falls back to the active-mode rule. + // Adopt the track's single pre-existing mode (strand guard — see header). const std::string& target = item.trackModes.size() == 1 ? *item.trackModes.begin() : activeMode; tags.push_back(AutoTag{item.guid, target}); @@ -173,27 +127,19 @@ std::vector planItemRetag(const std::vector& selected, std::vector ops; const bool untag = targetMode.empty(); // empty target ⇒ untag (→ Arrange default) for (const RetagItem& item : selected) { - if (item.guid.empty()) continue; // defensive; a real item always has a GUID - if (item.onManualLane) continue; // manual-lane item is EXEMPT — never retagged + if (item.guid.empty()) continue; + if (item.onManualLane) continue; 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& 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. + // Per track GUID, the modes it's visible in (tree-aware) — captures the + // folder-derived-visibility split trigger, not just own-item mode span. std::map> visibleModesOf; for (const Mode& mode : model.modes().all()) { const std::set vis = model.visibleTracks(tree, mode.id); @@ -204,58 +150,26 @@ LaneMintPlan planLaneMinting(const ViewModeModel& model, const FolderTree& tree, 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; + if (model.membership().isShowBoth(track.trackGuid)) continue; // never force-split - // 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 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 + if (item.onManualLane) continue; // exempt 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; + if (ownItemModes.empty()) continue; // no own media, nothing to confine - // 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; + if (!multiMode) continue; // single-mode: D1 whole-track parking still separates - // Lazy-mint: lanes to mint = ONLY the modes the track's OWN items actually occupy — - // never an empty reserved lane for a mode the track is merely derived-visible in. - // A folder whose own item is Design-only but which is derived-visible in Arrange too - // mints a Design lane ONLY (holding the item); it mints NO Arrange lane. Confinement - // still holds: with only a Design lane present, toggling to Arrange drives that lane's - // C_LANEPLAYS to 0 (it hides+silences) and no lane plays, so the track reads as an - // empty normal track — the Design item does not leak. The Arrange lane is minted on - // demand the moment an Arrange item first lands (a later mint tick sees ownItemModes - // gain Arrange). The visibility trigger above still decides WHETHER to split; it no - // longer inflates WHICH lanes are minted. - const std::set& laneModes = ownItemModes; + const std::set& laneModes = ownItemModes; // lazy-mint: own modes only - // Transition to lane-split: one managed lane per own-content mode (durable key = - // laneNameForMode(mode)), owned by that mode. plan.splits.push_back(LaneMintPlan::TrackSplit{ track.trackGuid, static_cast(laneModes.size())}); for (const std::string& mode : laneModes) { @@ -263,13 +177,9 @@ LaneMintPlan planLaneMinting(const ViewModeModel& model, const FolderTree& tree, 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 + if (item.onManualLane) continue; plan.assigns.push_back(LaneAssign{ item.guid, track.trackGuid, laneNameForMode(item.modeId)}); } @@ -278,13 +188,7 @@ LaneMintPlan planLaneMinting(const ViewModeModel& model, const FolderTree& tree, return plan; } -// --------------------------------------------------------------------------- -// planner helpers -// --------------------------------------------------------------------------- - TrackPlan makeParkPlan(const std::string& guid, int fxCount) { - // Parking contract: hide both panels, out of the mix, FX bypassed, every FX - // offline. All fixed zeros — park never consults a snapshot. TrackPlan p; p.flags = { {guid, Flag::ShowInTcp, 0}, @@ -298,8 +202,6 @@ TrackPlan makeParkPlan(const std::string& guid, int fxCount) { } TrackPlan makeRestorePlan(const std::string& guid, const TrackSnapshot& snap) { - // Restore contract: every driven flag returns to its SNAPSHOTTED value — never - // a hardcoded "on"/default. A flag captured at 0 restores to 0. TrackPlan p; p.flags = { {guid, Flag::ShowInTcp, snap.showInTcp}, @@ -319,15 +221,9 @@ std::string nextModeId(const ModeRegistry& modes, const std::string& currentMode if (all[i].id == currentModeId) return all[(i + 1) % all.size()].id; // wrap past the last } - // Active mode not in the registry (stale/unknown) — jump to the first mode as a - // sane home rather than returning "". - return all.front().id; + return all.front().id; // stale/unknown current id -> jump to the first mode } -// --------------------------------------------------------------------------- -// ViewModeModel -// --------------------------------------------------------------------------- - ViewModeModel::ViewModeModel() : activeModeId_(kArrangeModeId) {} bool ViewModeModel::setActiveMode(const std::string& modeId) { @@ -350,8 +246,7 @@ const TrackSnapshot* ViewModeModel::snapshot(const std::string& guid) const { } std::size_t ViewModeModel::reconcile(const std::set& liveGuids) { - // Prune snapshots for GUIDs the project no longer contains (see header for the - // deliberate snapshot-yes / membership-no asymmetry and the undo-delete rationale). + // See header: snapshots are pruned, membership is not (undo-delete rationale). std::size_t removed = 0; for (auto it = snapshots_.begin(); it != snapshots_.end();) { if (liveGuids.count(it->first) == 0) { @@ -368,7 +263,7 @@ bool ViewModeModel::leafBelongsToMode(const std::string& guid, const std::string const Membership* m = membership_.query(guid); if (!m) return modeId == kArrangeModeId; // untagged ⇒ Arrange default if (m->showBoth) return true; // show-both ⇒ every mode - if (m->modeIds.empty()) return modeId == kArrangeModeId; // show-both-cleared, no mode + if (m->modeIds.empty()) return modeId == kArrangeModeId; return m->modeIds.count(modeId) > 0; } @@ -376,28 +271,18 @@ std::set ViewModeModel::visibleTracks(const FolderTree& tree, const std::string& modeId) const { std::set visible; - // Pass 1: every node — leaf OR parent — that belongs to the mode by its OWN - // membership is visible. For a leaf this is the tagged/show-both/untagged-Arrange - // rule; for a parent it means an untagged folder (which carries its own FX/media - // and defaults to Arrange) shows in Arrange even when none of its children do. - // Parents ALSO become visible in pass 2 by derivation from a visible descendant; - // the two rules are OR'd, so an untagged folder of all-Design leaves shows in both - // Arrange (own default) and Design (derived). + // Pass 1: nodes visible by their own membership (leaf rule, or an + // untagged/Arrange-default folder). for (const auto& node : tree.nodes) { if (leafBelongsToMode(node.guid, modeId)) visible.insert(node.guid); } - // Pass 2: a parent is also visible if any descendant is visible. Walk each - // currently-visible node up its parent chain and mark ancestors. Seeding from the - // full pass-1 set means a parent made visible by its own membership propagates its - // visibility up the remaining ancestors too. Parent chains are read from the - // supplied tree only (no REAPER access). A cycle-guard bounds the walk in case a - // malformed tree links a node to itself. + // Pass 2: propagate up parent chains so a parent with any visible + // descendant is visible too (OR'd with pass 1). Cycle-guarded. std::map parentOf; for (const auto& node : tree.nodes) parentOf[node.guid] = node.parentGuid; - // Snapshot the pass-1 visible set so we don't re-walk parents we add mid-loop. const std::vector seeds(visible.begin(), visible.end()); for (const auto& node : seeds) { auto it = parentOf.find(node); @@ -415,52 +300,29 @@ std::set ViewModeModel::visibleTracks(const FolderTree& tree, TogglePlan ViewModeModel::planToggle(const FolderTree& tree, const std::string& targetMode) const { TogglePlan plan; - // The mode system manages EVERY leaf, not just tagged ones. An untagged leaf is - // an Arrange member (leafBelongsToMode resolves that), so it must park when the - // target mode is not Arrange and restore when it is — the same full park/restore - // a tagged leaf gets. Enumerating the FolderTree (not membership_.all()) is what - // brings untagged leaves — which are absent from the membership index — under - // management. Parents are visibility-only (handled by visibleTracks + the shell's - // parent-visibility pass) and show-both leaves are the always-visible escape; - // neither is ever parked. + // Enumerate the tree (not membership_.all()) so untagged leaves — absent + // from the membership index but still Arrange members — park/restore too. for (const auto& node : tree.nodes) { - if (node.isParent) continue; // parents are derived, never parked + if (node.isParent) continue; const std::string& guid = node.guid; - if (membership_.isShowBoth(guid)) continue; // show-both leaves are never parked + if (membership_.isShowBoth(guid)) continue; const bool active = leafBelongsToMode(guid, targetMode); if (active) { - // Returning to visibility: restore from snapshot if we have one. No - // snapshot ⇒ the track was never parked, nothing to restore. if (const TrackSnapshot* snap = snapshot(guid)) plan.restore.push_back(makeRestorePlan(guid, *snap)); } else { - // Inactive leaf (tagged into another mode, or untagged in a non-Arrange - // mode) ⇒ park. fxOffline is intentionally empty here: the D2 shell - // expands per-FX offline writes using TrackFX_GetCount. The pure model - // has no access to REAPER FX counts at plan time; makeParkPlan(guid, 0) - // emits only the scalar flags as a result. + // fxOffline is empty here; the D2 shell expands it via TrackFX_GetCount. plan.park.push_back(makeParkPlan(guid, /*fxCount=*/0)); } } - // D2 item-level projection: emit a C_LANEPLAYS op for every MANAGED lane. The - // active mode's lane plays exclusively; every other managed lane is silenced+hidden - // (laneModeState). MANUAL lanes are skipped entirely — the load-bearing invariant: - // a toggle never drives a lane the tool did not mint (the fixed-lane analog of - // "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. + // One C_LANEPLAYS op per MANAGED lane; manual lanes are skipped entirely. #ifndef NDEBUG - // Debug-time guard for the one-managed-lane-per-mode-per-track exclusivity - // assumption (see laneModeState). Two managed lanes on the same track claiming the - // same mode would both be told to play exclusively on that mode's toggle, which - // REAPER cannot honor. Cheap set membership over the (usually tiny) managed-lane - // set; compiled out of release builds. std::set> seenTrackMode; // (trackGuid, mode) #endif for (const auto& [ref, ownership] : lanes_.all()) { - if (!ownership.isManaged()) continue; // manual lanes are off-limits + if (!ownership.isManaged()) continue; #ifndef NDEBUG assert(seenTrackMode.insert({ref.trackGuid, *ownership.managedMode}).second && "two managed lanes on one track claim the same mode (exclusivity broken)"); @@ -473,9 +335,6 @@ TogglePlan ViewModeModel::planToggle(const FolderTree& tree, const std::string& } std::set ViewModeModel::lanesTouchedByToggle() const { - // Managed-only: exactly the lanes a toggle is permitted to drive. A manual lane — - // absent OR recorded manual in the ownership index — is never returned, so the shell - // can never write C_LANEPLAYS to a lane the user hand-manages. std::set touched; for (const auto& [ref, ownership] : lanes_.all()) { if (ownership.isManaged()) touched.insert(ref); @@ -488,14 +347,9 @@ bool ViewModeModel::operator==(const ViewModeModel& o) const { activeModeId_ == o.activeModeId_ && snapshots_ == o.snapshots_; } -// =========================================================================== -// JSON — writer -// =========================================================================== - namespace { -// Shared core/json emit helpers (Q-W1): same escape set + %d rendering as the -// prior file-local writer, so the emitted blob is byte-identical. +// Shared core/json emit helpers (Q-W1) — byte-identical escape/int rendering. using json::writeEscaped; using json::writeIntArray; std::string intToStr(int v) { return json::numToStr(v); } @@ -510,7 +364,6 @@ std::string ViewModeModel::serialize() const { root.keyRaw("version", intToStr(1)); root.keyStr("activeMode", activeModeId_); - // modes root.keyBegin("modes"); out += '['; { @@ -571,10 +424,7 @@ std::string ViewModeModel::serialize() const { } out += ']'; - // lanes: array of { trackGuid, laneKey, managed(bool), mode(str, managed only) }. - // A manual lane omits "mode"; managed carries the owning mode id. Emitting an - // explicit "managed" bool keeps a manual lane distinguishable from a managed lane - // whose mode string is (illegally) empty — the parser rejects the latter. + // lanes: array of { trackGuid, laneKey, managed(bool), mode(str, managed only) } root.keyBegin("lanes"); out += '['; { @@ -590,23 +440,12 @@ std::string ViewModeModel::serialize() const { } } out += ']'; - } // root closes here (see bank_model note on NRVO + deferred close) + } // root closes here (NRVO + deferred close, mirrors bank_model) return out; } -// =========================================================================== -// JSON — parser (recursive descent; false on any malformed input, never UB) -// =========================================================================== - namespace { -// The model DOMAIN grammar over the shared core/json lexical layer (Q-W1). - -// The registry starts seeded (Arrange + Design). Deserialization must reproduce the -// serialized set exactly, so we replace the seeded contents with the parsed ones — -// add() dedups by id, so a serialized Arrange/Design would otherwise be rejected as -// duplicates and the ordinals/names would not round-trip. We therefore parse into a -// fresh vector and swap. `reg` is passed empty (see parseModel). bool parseModes(json::Reader& r, ModeRegistry& reg) { if (!r.consume('[')) return false; r.skipWs(); @@ -659,18 +498,9 @@ bool parseMembership(json::Reader& r, MembershipIndex& idx) { } while (r.consume(',')); if (!r.consume('}')) return false; if (!haveGuid || guid.empty()) return false; - // Install the entry verbatim (tag() would clear a multi-mode set and drop - // show-both). A serialized entry is trusted to already satisfy the model's - // invariants. - // - // Deliberate tolerance: we do NOT validate that membership modeIds reference - // registered modes, and we do not validate snapshot GUIDs against the index. - // Stale-GUID and stale-mode tolerance is a stated invariant of this model — - // a deserialized entry is treated as trusted data, not as live cross-checked - // state. Rejecting stale entries here would violate that invariant. The one - // exception is activeMode (validated below in parseModel): a persisted active - // mode that no longer exists has an immediate behavioral consequence, so it - // is caught and the parse is rejected. + // Install verbatim (tag() would clobber a multi-mode set / show-both). + // Stale mode ids / stale GUIDs are tolerated by design — only + // activeMode is validated (below). if (!idx.restore(guid, mem)) return false; } while (r.consume(',')); return r.consume(']'); @@ -721,10 +551,8 @@ bool parseLanes(json::Reader& r, LaneOwnershipIndex& idx) { else if (!r.skipValue()) return false; } while (r.consume(',')); if (!r.consume('}')) return false; - // Both keys mandatory and non-empty (they form the lane's identity). A managed - // lane must carry a non-empty mode; a manual lane must not claim one. Enforcing - // this on parse keeps a round-tripped index byte-for-byte identical to the - // serialized one and rejects a malformed managed-without-mode entry. + // Both keys mandatory/non-empty; managed must carry a mode, manual must not + // — keeps a round-tripped index byte-for-byte identical to the source. if (!haveTrack || !haveLane || !haveManaged) return false; if (trackGuid.empty() || laneKey.empty()) return false; if (managed) { @@ -769,11 +597,7 @@ bool parseModel(json::Reader& r, ViewModeModel& out) { } else if (key == "lanes") { if (!parseLanes(r, lanes)) return false; } else { - // Unknown keys and the "version" field are skipped here. - // "version" is serialized as a forward-compat placeholder — there is no - // active version gate yet; all persisted data is parsed the same way - // regardless of the value. A future gate would add a version branch here. - if (!r.skipValue()) return false; + if (!r.skipValue()) return false; // unknown keys / "version" placeholder } } while (r.consume(',')); diff --git a/src/core/view/view_mode_model.h b/src/core/view/view_mode_model.h index bcd0765..db4408f 100644 --- a/src/core/view/view_mode_model.h +++ b/src/core/view/view_mode_model.h @@ -1,38 +1,9 @@ #pragma once -// view_mode_model — the pure core of the Design View feature, deliberately free of any -// REAPER type so it compiles and unit-tests OUTSIDE the DAW. It is the mirror of -// bank_model: it owns the mode registry, the GUID-keyed membership index, the -// folder-tree-aware visibility derivation, the parking/restore planner, and the -// JSON round-trip of all of it. -// -// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO -// vendor/ includes. Standard library only. The folder structure is an INPUT -// supplied by the D2 shell (which reads REAPER's I_FOLDERDEPTH); this model never -// fetches or stores REAPER's live tree — folder structure is REAPER's truth and -// changes underneath us, so it is passed in per query, not held. -// -// -- Representation decisions (design latitude exercised; invariants below) ----- -// -// * A mode is (stable string id, display name, ordinal). Arrange (id "arrange", -// ordinal 0) and Design (id "design", ordinal 1) are seeded. Arrange is the -// fallback home for every untagged leaf; structurally it is just another mode. -// -// * Membership is GUID -> { mode ids } (a set, not a bool) plus a per-track -// show-both flag. Normally a leaf is in exactly one mode; multiple only via the -// parent-derivation rule (computed, not stored) or the show-both escape hatch. -// An untagged GUID is NOT in the index and belongs to Arrange by default. -// -// * The planner drives exactly four scalar flags (showInTcp, showInMixer, -// mainSend, fxEnable) plus a per-FX offline list. Park values are fixed zeros -// (defined by the parking contract), so PARK ops need no snapshot. RESTORE ops -// come entirely FROM a TrackSnapshot captured before parking — never a hardcoded -// default. This is where the restore-contract invariant lives and is tested. -// -// * The snapshot stores the full prior per-FX offline vector so a save-while-parked -// project round-trips and restores each FX to its exact prior offline state. The -// pure model does NOT need REAPER FX counts to plan a park (park offlines all N, -// which the shell expands from TrackFX_GetCount); it only needs them to restore, -// and it gets them from the snapshot it captured. +// Pure core of the Design View feature — mirror of bank_model: mode registry, +// GUID-keyed membership, folder-tree-aware visibility, park/restore planner, +// and JSON round-trip. Folder structure is an INPUT (the D2 shell reads +// REAPER's I_FOLDERDEPTH); this model never fetches or stores REAPER's live +// tree. See src/core/view/CLAUDE.md for the settled invariants. #include #include @@ -56,31 +27,26 @@ struct Mode { bool operator==(const Mode& o) const; }; -// Ordered registry of modes. Arrange + Design are seeded on construction. Add more -// to prove the model is N-mode, not boolean. Ids are unique; adding a duplicate id -// is rejected. +// Ordered registry of modes. Arrange + Design are seeded on construction; ids +// are unique, adding a duplicate id is rejected. class ModeRegistry { public: ModeRegistry(); // seeds Arrange (ordinal 0) + Design (ordinal 1) - // Adds a mode. Rejects (returns false, no mutation) an empty or duplicate id. bool add(const Mode& mode); - // Returns the mode with `id`, or nullptr. Invalidated by any mutating call. const Mode* query(const std::string& id) const; bool contains(const std::string& id) const { return query(id) != nullptr; } - // All modes in ordinal order (ties broken by insertion order). const std::vector& all() const { return modes_; } std::size_t size() const { return modes_.size(); } bool operator==(const ModeRegistry& o) const { return modes_ == o.modes_; } - // An empty registry (no seed modes). Deserialization parses the persisted mode - // set into this and then owns it; the default ctor's seed would otherwise make - // the serialized Arrange/Design collide on add() and fail to round-trip. + // Empty registry (no seed modes) for deserialization, so the parsed + // Arrange/Design don't collide with the default ctor's seeded ones. static ModeRegistry makeEmpty() { return ModeRegistry(EmptyTag{}); } private: @@ -100,30 +66,12 @@ struct Membership { } }; -// -- Lane ownership (Phase D2 / two-canvas item-level projection) ------------- -// -// D2 extends the track-level projection to the ITEM level via REAPER fixed lanes -// (I_FREEMODE=2). On a track shared by two stances, each mode owns a fixed lane; a -// toggle shows/plays only the active mode's lane. This is the item-visibility analog -// of D1's track parking, and it carries the same load-bearing guarantee: -// -// THE TOOL DRIVES ONLY WHAT IT MINTED. A fixed-lane track is also REAPER's native -// comping surface — a user may keep their OWN manual lanes (comp takes, alternate -// reads). Mode operations touch ONLY managed lanes; manual lanes are never shown, -// hidden, silenced, or re-laned, and their C_LANEPLAYS stays exactly as set. This -// is the fixed-lane analog of "never touch B_MUTE/I_SOLO" and "never touch master". -// -// LANE IDENTITY IS AN OPAQUE, STABLE KEY SUPPLIED BY THE SHELL (boundary). The pure -// index keys a lane by (track GUID + a lane key string). The lane key is an OPAQUE -// identifier the shell provides; this model does NOT assume lane ordinals are stable -// and bakes in NO I_FIXEDLANE renumber/reorder assumptions. Whether the shell derives -// the key from a raw I_FIXEDLANE ordinal or a more durable identity — and how it keeps -// the index from going stale across lane reorder/renumber/deletion — is a Wave-2 SHELL -// design point (CONTEXT.md §Lane-identity fragility). The pure model's only contract: -// the same lane key denotes the same lane across calls. +// Item-level (fixed-lane) lane ownership. Mode operations touch only managed +// lanes; manual lanes are the user's own comping lanes and stay untouched — +// the fixed-lane analog of never-touch-mute/solo. Lane identity is an opaque +// key the shell supplies; this model bakes in no I_FIXEDLANE ordinal assumption. -// One lane's ownership: managed by a specific mode, or manual (user-minted, outside -// the mode system). `managedMode` present ⇒ managed by that mode id; absent ⇒ manual. +// One lane's ownership: managed by a specific mode, or manual (user-minted). struct LaneOwnership { std::optional managedMode; // set ⇒ managed by this mode; unset ⇒ manual @@ -133,10 +81,10 @@ struct LaneOwnership { bool operator==(const LaneOwnership& o) const { return managedMode == o.managedMode; } }; -// A lane's composite key: (track GUID, opaque lane key). Ordered so it can key a map. +// A lane's composite key: (track GUID, opaque lane key). struct LaneRef { std::string trackGuid; - std::string laneKey; // opaque, shell-supplied; NOT assumed to be a stable ordinal + std::string laneKey; // opaque, shell-supplied; not assumed to be a stable ordinal bool operator<(const LaneRef& o) const { if (trackGuid != o.trackGuid) return trackGuid < o.trackGuid; @@ -147,34 +95,22 @@ struct LaneRef { } }; -// (track GUID, lane key) -> ownership. Managed lanes name their owning mode; manual -// lanes are user-minted and off-limits to every mode operation. GUID-keyed and -// portable, it rides in the "reasampler" view_state alongside the membership index. -// A lane ABSENT from the index has no recorded ownership — the model treats an absent -// lane as manual by default (the tool never minted it), so the managed-only guarantee -// holds even before the index is populated. +// (track GUID, lane key) -> ownership, GUID-keyed and portable. A lane ABSENT +// from the index is treated as manual by default (never minted by the tool), +// so the managed-only guarantee holds even before the index is populated. class LaneOwnershipIndex { public: - // Records lane (trackGuid, laneKey) as MANAGED by `modeId`, replacing any prior - // ownership. Returns false if any argument is empty. bool setManaged(const std::string& trackGuid, const std::string& laneKey, const std::string& modeId); - // Records lane (trackGuid, laneKey) as MANUAL (user-minted), replacing any prior - // ownership. Returns false if trackGuid or laneKey is empty. bool setManual(const std::string& trackGuid, const std::string& laneKey); - // Removes the lane from the index entirely (⇒ treated as manual-by-default again). - // Returns true if it was present. + // Removes the lane entirely (⇒ manual-by-default again). Returns true if present. bool remove(const std::string& trackGuid, const std::string& laneKey); - // The ownership for a lane, or nullptr if the lane has no recorded entry (⇒ manual - // by default). Invalidated by any mutating call. const LaneOwnership* query(const std::string& trackGuid, const std::string& laneKey) const; - // True if the lane is recorded MANAGED (by any mode). A lane absent from the index - // is NOT managed (manual by default) — this is the load-bearing predicate the - // toggle planner and the "which lanes may this toggle touch" query gate on. + // Load-bearing predicate the toggle planner gates on: absent ⇒ not managed. bool isManaged(const std::string& trackGuid, const std::string& laneKey) const { const LaneOwnership* o = query(trackGuid, laneKey); return o && o->isManaged(); @@ -191,44 +127,32 @@ private: std::map entries_; // (guid, laneKey) -> ownership }; -// The play/show state a managed lane takes for a given active mode, matching REAPER's -// item/track-side C_LANEPLAYS values (SDK: 0=lane silent+hidden, 1=lane plays -// exclusively). A managed lane owned by the ACTIVE mode plays (1); every other managed -// lane is silenced+hidden (0) — consistent with exclusive membership and D1's "a mode -// flip is a real change, not cosmetic." Exposed as a free function for direct testing. -// managedMode == activeMode ⇒ 1 (plays exclusively) -// otherwise ⇒ 0 (does not play; hidden + silent) -// The caller must only pass MANAGED lanes here; manual lanes never reach this decision. +// C_LANEPLAYS value for a managed lane under the given active mode: the lane +// plays exclusively iff its owning mode is active, else silent+hidden. Callers +// must only pass MANAGED lanes; manual lanes never reach this decision. inline constexpr int kLanePlaysExclusive = 1; // C_LANEPLAYS: plays exclusively inline constexpr int kLaneSilent = 0; // C_LANEPLAYS: does not play (hidden+silent) int laneModeState(const std::string& managedMode, const std::string& activeMode); // GUID-keyed membership index. Untagged GUIDs are absent and belong to Arrange. -// Keyed by track GUID string, never index (reorder-safe). class MembershipIndex { public: - // Tags `guid` into `modeId`, replacing any prior mode set (a leaf lives in one - // mode; use showBoth for the cross-mode case). No-op-safe on repeated calls. - // Returns false if guid or modeId is empty. + // Tags `guid` into `modeId`, replacing any prior mode set. Returns false if + // guid or modeId is empty. bool tag(const std::string& guid, const std::string& modeId); - // Removes `guid` from the index entirely (returns it to the Arrange default). - // Returns true if it was present. + // Removes `guid` entirely (returns it to the Arrange default). bool untag(const std::string& guid); - // Sets the show-both flag for `guid`. Tags the guid into no new mode; if the - // guid is untagged it is created with an empty mode set (Arrange default) so - // show-both alone is representable. Returns false if guid is empty. + // Sets the show-both flag; creates an untagged (Arrange-default) entry if + // `guid` had none, so show-both alone is representable. bool setShowBoth(const std::string& guid, bool showBoth); - // Installs a complete membership record verbatim (multi-mode set + show-both), - // replacing any existing entry for `guid`. Used by deserialization to rebuild a - // trusted, already-valid entry without tag()'s single-mode clobbering. Returns - // false if guid is empty. + // Installs a complete membership record verbatim, replacing any existing + // entry. Used by deserialization to rebuild a trusted entry without tag()'s + // single-mode clobbering. bool restore(const std::string& guid, const Membership& membership); - // Returns the membership for `guid`, or nullptr if untagged. Invalidated by any - // mutating call. const Membership* query(const std::string& guid) const; bool isShowBoth(const std::string& guid) const { @@ -250,40 +174,32 @@ private: std::map entries_; // guid -> membership }; -// -- Folder tree (INPUT, not stored) ---------------------------------------- -// -// The shell builds this from I_FOLDERDEPTH each time and passes it to a visibility -// query. A node is a leaf or a parent; a parent is visible in a mode if it belongs -// to that mode by its own membership OR any of its descendant leaves does, and is -// never parked. The master track is -// modeled implicitly (always visible, never touched) and is NOT a node here. +// Folder tree: an INPUT the shell rebuilds from I_FOLDERDEPTH each call, never +// stored here. A parent is visible in a mode if it belongs by its own +// membership or any descendant leaf does, and is never parked. The master +// track is implicit (always visible, untouched) and is not a node here. struct FolderNode { std::string guid; std::string parentGuid; // empty ⇒ top-level (child of master / project root) bool isParent = false; // true if this node has descendant tracks (a folder) }; -// A flat parent↔child description of the current track tree. Order is arrange-view -// order; parentGuid links each node to its immediate parent folder. +// Arrange-view order; parentGuid links each node to its immediate parent folder. struct FolderTree { std::vector nodes; }; -// -- Snapshot + planner ------------------------------------------------------ - -// The prior value of every tool-driven flag on one track, captured BEFORE parking. -// Restore uses these values verbatim — the restore contract's source of truth. -// Flags mirror REAPER's numeric representation (0/1 for the bools) so the shell -// applies them without translation; ints, not bools, so a snapshot faithfully -// round-trips whatever REAPER reported (defensive against non-0/1 values). +// The prior value of every tool-driven flag on one track, captured BEFORE +// parking — restore's source of truth. Ints, not bools, so a snapshot +// faithfully round-trips whatever REAPER reported (defensive against +// non-0/1 values). struct TrackSnapshot { int showInTcp = 0; // B_SHOWINTCP prior value int showInMixer = 0; // B_SHOWINMIXER prior value int mainSend = 0; // B_MAINSEND prior value int fxEnable = 0; // I_FXEN prior value - // Prior per-FX offline state, index = fx slot. Lets restore return each FX to - // exactly its captured offline value rather than a blanket "online". + // Prior per-FX offline state, index = fx slot. std::vector fxOffline; bool operator==(const TrackSnapshot& o) const { @@ -293,8 +209,8 @@ struct TrackSnapshot { } }; -// Which scalar flag a TrackFlagOp drives. FX-offline is carried separately (it is -// per-slot, variable length), see TrackParkPlan::fxOffline. +// Which scalar flag a TrackFlagOp drives. FX-offline is carried separately (it +// is per-slot, variable length) — see TrackParkPlan::fxOffline. enum class Flag { ShowInTcp, // B_SHOWINTCP ShowInMixer, // B_SHOWINMIXER @@ -324,13 +240,10 @@ struct FxOfflineOp { } }; -// One managed-lane play/show write the shell must apply. The shell translates this -// into the REAPER lane setters (track-side C_LANEPLAYS:N and, per item, I_FIXEDLANE / -// C_LANEPLAYS; B_FIXEDLANE_HIDDEN follows from the play state). `lanePlays` is a -// C_LANEPLAYS value: kLanePlaysExclusive when the active mode owns the lane, -// kLaneSilent otherwise. The pure model emits these for MANAGED lanes ONLY — never a -// manual lane (the fixed-lane analog of "never touch mute/solo"), enforced in -// planToggle and mirrored by lanesTouchedByToggle. +// One managed-lane play/show write the shell must apply (translated into +// C_LANEPLAYS / I_FIXEDLANE / B_FIXEDLANE_HIDDEN). Emitted for MANAGED lanes +// only — never a manual lane; enforced in planToggle and mirrored by +// lanesTouchedByToggle. struct LanePlayOp { std::string trackGuid; std::string laneKey; // opaque, shell-supplied @@ -341,38 +254,33 @@ struct LanePlayOp { } }; -// The complete set of operations to park one inactive leaf, or restore one leaf. -// Park uses fixed zeros (parking contract); restore uses a snapshot's values. -// fxOffline is emitted per known FX slot: on park, from the snapshot's slot count -// (all -> offline); on restore, each slot back to its captured value. +// The complete set of operations to park one inactive leaf, or restore one +// leaf. Park uses fixed zeros; restore uses a snapshot's values. fxOffline is +// per known FX slot: on park all slots go offline (from the snapshot's slot +// count); on restore each slot returns to its captured value. struct TrackPlan { std::vector flags; std::vector fxOffline; }; -// The plan for a whole toggle to a target mode: which tracks to park, and which to -// restore from their snapshots. Parents and show-both leaves never appear here — -// they are derived-visible and never parked (visibility is answered separately by -// visibleTracks). Untagged LEAVES DO appear: an untagged leaf is an Arrange member, -// so it parks in every non-Arrange mode and restores in Arrange — the mode system -// manages all leaves, not only tagged ones. +// The plan for a toggle to a target mode. Parents and show-both leaves never +// appear (derived-visible, never parked — see visibleTracks). Untagged +// leaves DO appear: an untagged leaf is an Arrange member, so it parks in +// every non-Arrange mode and restores in Arrange. struct TogglePlan { std::vector park; // inactive leaves -> parked (fixed zeros) std::vector restore; // active leaves returning -> snapshot values - // D2 item-level projection: per managed lane, the C_LANEPLAYS state for the target - // mode (active mode's lane plays; every other managed lane silenced+hidden). MANAGED - // lanes ONLY — a manual lane never appears here. Empty when no managed lanes exist, - // so a D1-only project (no fixed lanes) produces an identical plan to before. + // Per managed lane, the C_LANEPLAYS state for the target mode. Managed + // lanes only. Empty when no fixed lanes exist, so a lane-free project + // produces an identical plan to before fixed-lane support. std::vector lanes; }; -// -- The view mode model ----------------------------------------------------- -// -// Owns the mode registry, the membership index, the active mode, and the durable -// per-track snapshots (kept for tracks currently parked so a save-while-parked -// project restores correctly). Visibility and the toggle plan are computed against -// a supplied FolderTree — the tree is never stored. +// Owns the mode registry, membership index, active mode, and durable +// per-track snapshots (kept while parked so a save-while-parked project +// restores correctly). Visibility and the toggle plan are computed against a +// supplied FolderTree — the tree is never stored. class ViewModeModel { public: ViewModeModel(); // Arrange + Design seeded; active mode = Arrange @@ -385,82 +293,60 @@ public: const LaneOwnershipIndex& lanes() const { return lanes_; } const std::string& activeModeId() const { return activeModeId_; } - // Sets the active mode. Returns false (no change) if the id is not registered. + // Returns false (no change) if the id is not registered. bool setActiveMode(const std::string& modeId); - // Records / clears the pre-park snapshot for a track. The shell calls store - // before it parks a track; the model persists it so restore survives a save. + // The shell calls store before it parks a track, so restore survives a save. void storeSnapshot(const std::string& guid, const TrackSnapshot& snap); void clearSnapshot(const std::string& guid); const TrackSnapshot* snapshot(const std::string& guid) const; const std::map& snapshots() const { return snapshots_; } - // Prunes orphaned per-track state: drops every snapshot whose GUID is NOT in - // `liveGuids` (the set of GUIDs the shell currently enumerates from the project). - // Returns the number of snapshots removed. The shell calls this before planning a - // toggle; because reapply-on-load also routes through the shell's applyMode, this - // reconciles on project open too. + // Drops every snapshot whose GUID is NOT in `liveGuids`. Returns the count + // removed. // - // Why snapshots and NOT membership: a parked track's snapshot is dead weight once - // the track is deleted — it can never be restored, and if REAPER reuses that GUID - // for a different track a stale snapshot would drive an INCORRECT restore. So it - // must be pruned. Membership is deliberately KEPT: REAPER's undo of a track delete - // restores the SAME GUID, so dropping the Design tag on delete would silently lose - // it on undo-delete. Keeping membership means an undone delete brings the track - // back correctly tagged and it re-snapshots + re-parks cleanly on the next toggle. - // A genuinely-deleted-and-never-restored track leaves only a tiny dormant - // membership entry — acceptable, and far better than losing tags on undo. Folder - // RESTRUCTURE (moving tracks without deleting) is already self-healing: the tree is - // rebuilt from I_FOLDERDEPTH every toggle, so a restructure leaves every GUID live - // and reconcile is a no-op over it. This handles DELETION specifically. + // Snapshots are pruned, membership is not: a parked track's snapshot is + // dead weight once the track is deleted (can never restore; a reused GUID + // would drive an incorrect restore). Membership survives because REAPER's + // undo of a track delete restores the SAME GUID — dropping the tag on + // delete would lose it on undo. A never-restored track leaves only a + // dormant membership entry, which is a fine trade against losing tags on + // undo. Folder restructure is self-healing (tree rebuilt every toggle) and + // is not what this handles. std::size_t reconcile(const std::set& liveGuids); - // Does `guid` belong to `modeId`? A leaf belongs if it is tagged into modeId, - // is show-both (belongs everywhere), or is untagged and modeId is Arrange (the - // default). Parent derivation is NOT applied here — this is the LEAF rule; use - // visibleTracks for the tree-aware answer. + // A leaf belongs if tagged into modeId, show-both, or untagged with modeId + // == Arrange. No parent derivation here — see visibleTracks for that. bool leafBelongsToMode(const std::string& guid, const std::string& modeId) const; - // The set of track GUIDs visible in `modeId`, tree-aware: active leaves, - // show-both leaves, and every parent that EITHER belongs to the mode by its own - // membership OR has at least one descendant visible in the mode. Untagged nodes - // (leaf or folder) count as Arrange, so an untagged folder carrying its own - // FX/media shows in Arrange even when none of its children do, and additionally - // shows in a child's mode by derivation. Stale GUIDs in the tree are tolerated. - // The master is not represented (always visible; the shell never touches it). + // Tree-aware visible set: active leaves, show-both leaves, and every + // parent that belongs to the mode itself or has a visible descendant. + // Untagged nodes count as Arrange. Stale tree GUIDs are tolerated; the + // master is not represented (always visible, untouched). std::set visibleTracks(const FolderTree& tree, const std::string& modeId) const; - // Plans a toggle to `targetMode` by enumerating EVERY leaf in the supplied tree. - // A leaf inactive in the target mode — tagged into another mode, or untagged and - // the target isn't Arrange — is parked with fixed zeros; a leaf that becomes - // active AND has a stored snapshot is restored from it. Parents (visibility-only) - // and show-both leaves (always visible) are never parked; the master is not in - // the tree. Untagged leaves ARE managed: they are Arrange members, so they park - // in non-Arrange modes and restore in Arrange. Tree membership is the enumeration - // source, so stale membership GUIDs absent from the tree are naturally ignored. + // Enumerates every leaf in `tree`; a leaf inactive in `targetMode` is + // parked (fixed zeros), one becoming active with a stored snapshot is + // restored from it. Parents and show-both leaves are never parked. + // Untagged leaves are Arrange members and park/restore accordingly. Tree + // membership is the enumeration source, so stale membership GUIDs absent + // from the tree are ignored. // - // Note: park plans emitted here have an empty fxOffline vector. The D2 shell - // expands per-FX offline writes using TrackFX_GetCount — the pure model has no - // access to REAPER FX counts at plan time. + // Park plans here carry an empty fxOffline vector — the D2 shell expands + // per-FX offline writes via TrackFX_GetCount (not available to the pure model). TogglePlan planToggle(const FolderTree& tree, const std::string& targetMode) const; - // The managed-only "which lanes may this toggle touch" query: the set of lane refs - // a toggle is permitted to drive — MANAGED lanes ONLY, from the ownership index. - // Manual lanes are NEVER in the result, regardless of target mode. This is the pure, - // testable decision behind the load-bearing invariant; the shell reads live lane - // state and applies C_LANEPLAYS only to lanes this query returns. Independent of the - // folder tree (lane ownership is not a tree property) — the target mode does not - // filter the SET (every managed lane is touchable), only the play VALUE each takes - // (see planToggle / laneModeState). + // Managed lanes only, from the ownership index — the set a toggle may + // drive. Independent of the folder tree (lane ownership isn't a tree + // property); the target mode decides each lane's play VALUE, not the set. std::set lanesTouchedByToggle() const; bool operator==(const ViewModeModel& o) const; std::string serialize() const; - // Parses a JSON string produced by serialize(). std::nullopt on malformed - // input. On success deserialize(serialize(x)) == x. + // std::nullopt on malformed input. deserialize(serialize(x)) == x on success. static std::optional deserialize(const std::string& json); private: @@ -471,61 +357,38 @@ private: std::map snapshots_; // guid -> pre-park snapshot }; -// Builds the fixed-zero park plan for one leaf. Offlines `fxCount` slots. Exposed -// for the shell and for direct testing of the parking contract. +// Fixed-zero park plan for one leaf, offlining `fxCount` slots. TrackPlan makeParkPlan(const std::string& guid, int fxCount); -// Builds the restore plan for one leaf from its snapshot — every flag set to its -// captured value, never a default. Exposed for the shell and for testing the -// restore-contract invariant directly. +// Restore plan for one leaf from its snapshot — every flag to its captured +// value, never a default. TrackPlan makeRestorePlan(const std::string& guid, const TrackSnapshot& snap); -// -- Auto-tag decision (Phase D2) -------------------------------------------- +// Auto-tag decision: new content takes the active mode at creation; the +// Wave-2 shell diffs GUIDs on the panel timer and asks this what to tag. +// Pre-existing content never reaches here. // -// New content — both new tracks and new items — is tagged to whatever mode is active -// when it is created; pre-existing content defaults to Arrange. The DECISION is pure: -// the Wave-2 shell detects new GUIDs by diffing project state on the panel timer and -// asks this function what to tag. Pre-existing content (a GUID the shell does not -// report as new) never reaches here and stays at its index state (Arrange by default). +// Manual-lane exemption: an item landing on a MANUAL lane is off-limits. // -// Manual-lane exemption: an item that landed in a MANUAL lane is off-limits to auto-tag -// — auto-tag governs normal timeline content, not hand-managed lanes. The shell marks -// such an item `onManualLane = true` (it knows the item's lane and consults the -// ownership index); the decision then emits NO tag for it. New tracks and new items on -// managed/no lane follow the active-mode rule. -// -// -- Pre-existing-content adoption (strand fix) ------------------------------- -// -// A new item dropped onto a track that ALREADY carries currently-visible content must -// not silently push that track into a different mode. If the pre-existing content -// resolves to ONE mode and the new item were blindly tagged to the (different) ACTIVE -// mode, the track would become multi-mode, planLaneMinting would split it, and the -// toggle would silence whichever lane the active mode does not own — stranding the -// pre-existing, previously-visible items on a C_LANEPLAYS=0 lane with no user intent. -// -// The rule: a new item ADOPTS the single mode of the pre-existing content already on its -// track. Only when the track carries no pre-existing managed-eligible content (an empty -// or brand-new track), or when that content already spans multiple modes (an existing -// deliberate split, which the new item joins under the active mode), does the new item -// fall back to the active-mode rule. Deliberate two-take splits are unaffected: those go -// through the explicit item mode-move actions (planItemRetag), never auto-tag. -// The shell reports each new item's track pre-existing-content modes in `trackModes`. +// Adoption (strand fix): a new item on a track that already carries +// pre-existing content adopts that content's single mode rather than blindly +// taking the active mode — otherwise the track would go multi-mode, get +// lane-split, and strand the pre-existing (previously visible) items on a +// silenced lane with no user intent. Falls back to the active mode only when +// the track has no pre-existing managed-eligible content, or that content +// already spans multiple modes (an existing deliberate split). -// One new item the shell detected this poll. Its lane disposition decides exemption; its -// track's pre-existing content modes decide adoption (see above). +// One new item the shell detected this poll. struct NewItem { std::string guid; - bool onManualLane = false; // true ⇒ EXEMPT from auto-tag (hand-managed lane) - // The distinct modes the PRE-EXISTING (not-new-this-tick) managed-eligible content on - // this item's track resolves to. Empty ⇒ the item's track carried no prior content, so - // the item takes the active mode. Exactly one ⇒ ADOPT that mode (the strand guard). - // More than one ⇒ the track is already a deliberate split; the item takes the active - // mode. The shell fills this by resolving each pre-existing item's mode from membership. + bool onManualLane = false; // true ⇒ EXEMPT from auto-tag + // Distinct modes the pre-existing (not-new-this-tick) content on this + // item's track resolves to. Empty ⇒ take active mode. Exactly one ⇒ + // adopt it. More than one ⇒ already a deliberate split, take active mode. std::set trackModes; }; -// One membership write the auto-tag decision produced: tag `guid` into `modeId`. The -// shell applies it to the MembershipIndex (a new track/item joins the active mode). +// One membership write: tag `guid` into `modeId`. struct AutoTag { std::string guid; std::string modeId; @@ -533,42 +396,27 @@ struct AutoTag { bool operator==(const AutoTag& o) const { return guid == o.guid && modeId == o.modeId; } }; -// The pure auto-tag decision: given the new track GUIDs and new items detected this -// poll plus the active mode, produce the membership writes. Every new track is tagged -// to `activeMode`. Every new item is tagged UNLESS it landed on a manual lane (exempt); -// its target mode is the single mode of its track's pre-existing content (adoption — the -// strand guard) when that content resolves to exactly one mode, otherwise `activeMode`. -// An empty `activeMode` yields no tags (nothing to tag into). Empty GUIDs are skipped. -// The result is a plan the shell applies; this function mutates nothing. +// Every new track is tagged to `activeMode`. Every new item is tagged unless +// exempt (manual lane); its target is the adopted single mode of its track's +// pre-existing content, else `activeMode`. Empty `activeMode` yields no tags. +// Empty GUIDs are skipped. Mutates nothing. std::vector autoTagNewContent(const std::vector& newTrackGuids, const std::vector& newItems, const std::string& activeMode); -// -- Item-level mode-move decision (Phase D2 / Wave 3-B) --------------------- -// -// The bindable item actions (Move selected items -> Design / -> Arrange / Untag) -// retag the CURRENT item selection's membership, then re-drive the minting/apply -// path so each moved item lands on its target mode's managed lane. The DECISION — -// which selected items to retag, and to what — is pure and unit-tested here; the -// shell only reads the item selection (GUID + manual-lane disposition) and applies -// the resulting membership writes + re-lane pass. -// -// MANAGED-LANES-ONLY INVARIANT (upheld at the source, exactly as auto-tag does): an -// item the shell reports as already on a MANUAL lane is EXEMPT — it is never retagged, -// never untagged, never re-laned. The tool drives only what it minted, even under an -// explicit user action. The shell reports `onManualLane` per item and this decision -// emits NO op for such items; the shell then skips them entirely. +// Item-level mode-move decision (bindable "Move selected items -> mode" +// actions): which selected items to retag, and to what. Manual-lane items +// (shell-reported `onManualLane`) are exempt — never retagged, never re-laned, +// upholding the managed-lanes-only invariant under an explicit user action too. -// 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). +// One selected item the shell reports for the retag decision. struct RetagItem { std::string guid; - bool onManualLane = false; // true ⇒ EXEMPT from the item mode-move actions + bool onManualLane = false; // true ⇒ EXEMPT }; -// 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. +// One membership op: `untag` removes the item (Arrange default); otherwise +// tags it into `modeId`. struct ItemRetagOp { std::string guid; bool untag = false; // true ⇒ untag; false ⇒ tag into modeId @@ -579,77 +427,48 @@ struct ItemRetagOp { } }; -// 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. +// Empty `targetMode` means untag (Move -> Arrange and Untag collapse to the +// same act, mirroring the track-level doUntag). Manual-lane and empty-GUID +// items are skipped. Mutates nothing. std::vector planItemRetag(const std::vector& selected, const std::string& targetMode); -// -- Lane minting decision (Phase D2 / Wave 3) ------------------------------- +// Lane-minting decision (D2 Wave 3): once a track is visible in more than one +// mode while carrying its own media, whole-track parking can no longer keep +// stances separate, so it drops to fixed lanes — one managed lane per +// involved mode, each item assigned to its mode's lane. // -// 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 independent triggers, either +// splits the track: (a) the track's own items span >= 2 modes, or (b) the +// track is a content-bearing folder derived-visible in >= 2 modes +// (visibleTracks) even though its own item is single-mode — the folder case +// a naive own-item-span check would miss. // -// "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 tracks are skipped outright (never force-split — the point of +// show-both is staying audible everywhere). Manual-lane items are exempt. +// Lanes are minted LAZILY — only for modes the track's own items actually +// occupy, never an empty reserved lane for a merely-derived-visible mode; +// confinement still holds because an absent lane never plays. // -// 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). +// Idempotent: re-reporting an already-split track yields the same mints and +// assignments, so re-running detection does not thrash the project or undo +// history. -// 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). +// One item the shell reports for the minting decision. 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. +// One track the shell reports: its GUID plus the items on it. struct LaneTrack { std::string trackGuid; std::vector items; }; -// One item→lane assignment the shell must apply (I_FIXEDLANE = the lane the durable -// key `laneKey` currently occupies; the shell resolves key→ordinal exactly as the -// C_LANEPLAYS apply path does). Only managed-eligible items appear here. +// One item→lane assignment the shell must apply (I_FIXEDLANE = the lane the +// durable key `laneKey` currently occupies). Only managed-eligible items appear. struct LaneAssign { std::string itemGuid; std::string trackGuid; @@ -660,8 +479,8 @@ struct LaneAssign { } }; -// 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). +// One managed lane the shell must mint: its durable key (== the P_LANENAME to +// stamp) and the mode that owns it (an ownership-index write). struct LaneMint { std::string trackGuid; std::string laneKey; // == laneNameForMode(modeId); the P_LANENAME to stamp @@ -672,15 +491,13 @@ struct LaneMint { } }; -// 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. +// The complete plan; empty when no track needs splitting (D1 behavior +// unchanged). The shell wraps application in one Undo block (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). + // Tracks to switch into fixed-lane mode (I_FREEMODE=2, I_NUMFIXEDLANES >= + // laneCount). Idempotent — an already-split track still appears, but the + // shell's ensure is then a no-op. struct TrackSplit { std::string trackGuid; int laneCount = 0; // number of managed lanes this track needs @@ -694,55 +511,16 @@ struct LaneMintPlan { } }; -// 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). +// `model` supplies membership + show-both state; `tree` supplies folder +// structure for the derived-visibility trigger. Items with an empty GUID or +// modeId are skipped (defensive). Mutates nothing. LaneMintPlan planLaneMinting(const ViewModeModel& model, const FolderTree& tree, const std::vector& tracks); -// The next mode id in the registry's ordinal order, cycling past `currentModeId` -// and wrapping to the first mode after the last (Arrange -> Design -> Arrange with -// the two seed modes; the same cycle scales to N modes with no call-site change). -// This is the pure decision behind the "toggle active mode" action: the shell reads -// the model's active mode, asks for the next one, and applies it. -// * empty registry -> "" (nothing to cycle to) -// * currentModeId not present -> the first mode's id (a sane home to jump to) -// Exposed as a free function (not a model member) so it is unit-testable against a -// bare ModeRegistry without a full ViewModeModel. +// Next mode id in ordinal order, cycling past `currentModeId` and wrapping +// after the last. Empty registry -> "". currentModeId not present -> the +// first mode's id. Free function (not a model member) so it is testable +// against a bare ModeRegistry. std::string nextModeId(const ModeRegistry& modes, const std::string& currentModeId); } // namespace reasampler diff --git a/src/core/view/view_tree.cpp b/src/core/view/view_tree.cpp index 6356ec0..59af866 100644 --- a/src/core/view/view_tree.cpp +++ b/src/core/view/view_tree.cpp @@ -1,4 +1,4 @@ -// view_tree — pure folder-depth walk. See view_tree.h. +// See view_tree.h. #include "core/view/view_tree.h" @@ -8,11 +8,8 @@ FolderTree buildFolderTree(const std::vector& entries) { FolderTree tree; tree.nodes.reserve(entries.size()); - // Stack of currently-open folder-parent GUIDs. The top is the immediate parent - // of the next track. A folder-parent track opens its folder AFTER contributing - // its own node (its own parent is the enclosing folder), so the push trails the - // assignment. A closing track belongs to the folder it closes, so the pop also - // trails the assignment. + // Stack of open folder-parent GUIDs; top is the next track's parent. A + // folder-parent's push and a closer's pop both trail their own assignment. std::vector open; for (const TrackFolderEntry& e : entries) { @@ -23,11 +20,8 @@ FolderTree buildFolderTree(const std::vector& entries) { tree.nodes.push_back(node); if (e.folderDepth == 1) { - open.push_back(e.guid); // this track's folder opens for what follows + open.push_back(e.guid); } else if (e.folderDepth < 0) { - // Closes |folderDepth| levels after this (already-assigned) track. - // Clamp to the stack size so a malformed/stale depth stream can't - // underflow — the walk stays total. int levels = -e.folderDepth; while (levels-- > 0 && !open.empty()) { open.pop_back(); diff --git a/src/core/view/view_tree.h b/src/core/view/view_tree.h index 7d2d968..8122644 100644 --- a/src/core/view/view_tree.h +++ b/src/core/view/view_tree.h @@ -1,12 +1,6 @@ #pragma once -// view_tree — the ONE genuinely pure piece of the D2 view shell: turning REAPER's -// linear I_FOLDERDEPTH stream into the parent<->child FolderTree the pure model -// consumes. The REAPER reads (GetTrack / GetTrackGUID / I_FOLDERDEPTH) stay in -// view.cpp; this tree arithmetic is REAPER-free so the fiddly folder-depth walk is -// unit-tested outside the DAW (mirrors capture_paths splitting the path math out). -// -// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO -// vendor/ includes. Standard library + view_mode_model.h (for FolderTree) only. +// Pure I_FOLDERDEPTH -> FolderTree walk; REAPER reads stay in shell/view. +// See src/core/view/CLAUDE.md. #include #include @@ -15,19 +9,15 @@ namespace reasampler::view { -// One track's contribution to the folder walk, read from REAPER in arrange order. -// folderDepth is I_FOLDERDEPTH verbatim: 0 = normal, 1 = folder parent (opens a -// folder after this track), <0 = closes |folderDepth| folder levels after this -// track (-1 last in innermost, -2 last in innermost + next-innermost, ...). +// One track's contribution, read from REAPER in arrange order. folderDepth is +// I_FOLDERDEPTH verbatim: 0 normal, 1 opens a folder, <0 closes |depth| levels. struct TrackFolderEntry { std::string guid; int folderDepth = 0; }; -// Walks the ordered entries, tracking the open-folder stack, and assigns each -// node its immediate parentGuid (empty = top level) and isParent (opens a folder). -// Pure and total: tolerates malformed depth streams (a close deeper than the stack -// is clamped to empty) so a corrupt/stale project can never fault the shell. +// Assigns each node its parentGuid (empty = top level) and isParent. Total: a +// malformed depth stream (close deeper than the stack) clamps rather than faults. FolderTree buildFolderTree(const std::vector& entries); } // namespace reasampler::view diff --git a/src/core/wire/assignment_request.cpp b/src/core/wire/assignment_request.cpp index ab05c53..8ac9d27 100644 --- a/src/core/wire/assignment_request.cpp +++ b/src/core/wire/assignment_request.cpp @@ -10,9 +10,6 @@ namespace { constexpr const char* kMagic = "rsassign1"; -// The shared core/wire codec (Q-W1, T2-01b) — the same field grammar + hardening -// this file previously carried as its own Cursor copy. "never UB, never a -// partial value" is upheld in the codec. using wire::putField; using Cursor = wire::Cursor; diff --git a/src/core/wire/assignment_request.h b/src/core/wire/assignment_request.h index 12e469a..90f5dec 100644 --- a/src/core/wire/assignment_request.h +++ b/src/core/wire/assignment_request.h @@ -1,41 +1,18 @@ #pragma once -// assignment_request — the pure core of the S8 ingest assignment-request seam. +// assignment_request — pure core of the ingest assignment-request seam. No +// REAPER/SWELL/VST3/vendor includes; unit-tested outside the DAW. // -// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO VST3, -// NO vendor/ includes. Standard library only. Unit-tested outside the DAW — the same -// "small pure type + length-prefixed round-trip" pattern as provenance / owned_manifest. +// When the extension ingests a sample it writes an assignment request to its +// own ext-state namespace: "the active sampler instance should now play THIS +// sample." Owns only the wire format — the persist shell writes it, the +// instrument reads it, both must agree on the byte layout. The extension +// writing its own namespace does not violate the instrument's +// read-only-over-the-bank rule. // -// -- What it is -------------------------------------------------------------- -// -// When the EXTENSION ingests a sample (S8: arrange capture / Media-Explorer import / -// drop-onto-panel) it writes an ASSIGNMENT REQUEST to its own "reasampler" ext-state -// namespace: "the active sampler instance should now play THIS sample." The value -// names the ingested sample by (bankId, sampleId) plus a monotonic `generation` the -// reader compares to decide the request is NEW (a fresh ingest, even of the same id). -// -// This module owns ONLY the value's WIRE FORMAT — build/parse round-trip. Writing it -// to ext-state is the persist shell's job; READING it is the instrument's job in a -// LATER dispatch (S8 instrument-side follow-up, after S10 merges). This is why the -// format is documented here in the header, not just in code: the reader lands elsewhere -// and must decode exactly what this writer produced. -// -// -- The data-ownership boundary (load-bearing) ------------------------------ -// -// The EXTENSION writes this; the instrument only READS it. That does not violate the -// instrument's read-only-over-the-bank rule: the assignment request is the extension -// writing its OWN namespace (a request FROM the extension TO the instrument), never the -// instrument writing back into the bank. The instrument, on reading a new generation, -// updates its OWN component-state selection (the same selection S4 persists) and reloads. -// -// -- Why `generation` ------------------------------------------------------- -// -// Instances reference sample IDs, so re-assigning the SAME id (e.g. a recapture, or a -// re-drop of the same file) would be indistinguishable from a stale value without a -// changing field. `generation` is a monotonic disambiguator (the ingest writer supplies -// a wall-clock unix-epoch stamp today — see the writer shell) so the reader can tell -// "assigned again just now" from "already saw this." It is DELIBERATELY the same shape -// the S9 bank-generation counter will use, but it is NOT that counter — S9 is a separate -// point; this field is self-contained to the request and does not depend on S9 landing. +// `generation` exists because re-assigning the SAME (bankId, sampleId) would +// be indistinguishable from a stale value without a changing field; the +// writer supplies a unix-epoch stamp so the reader can tell "assigned again +// just now" from "already saw this." #include #include @@ -44,11 +21,8 @@ namespace reasampler::wire { // One assignment request: the ingested sample's identity + a monotonic disambiguator. -// bankId — the bank the sample was ingested into (the active/target bank). -// sampleId — the ingested Sample's stable id (BankModel key). -// generation — a monotonic value the reader compares to detect a NEW request. The -// writer supplies a unix-epoch-seconds stamp; the reader treats it as an -// opaque "did this change?" token, not a wall-clock it interprets. +// generation is an opaque "did this change?" token (writer supplies unix-epoch +// seconds); the reader never interprets it as a wall-clock. struct AssignmentRequest { std::string bankId; std::string sampleId; @@ -61,29 +35,19 @@ struct AssignmentRequest { bool operator!=(const AssignmentRequest& o) const { return !(*this == o); } }; -// Encode an assignment request to the wire string. Length-prefixed fields behind a -// magic+version tag ("rsassign1"), so arbitrary bytes in an id (a GUID, a display- -// derived id) round-trip whole with no escaping ambiguity — the same idiom provenance -// uses. Deterministic: the same request always yields the same string. -// -// FORMAT (documented for the LATER instrument-side reader): +// Length-prefixed fields behind a magic+version tag, so arbitrary bytes in an +// id round-trip whole with no escaping ambiguity. Deterministic. // "rsassign1" ':' ':' ':' -// where each is the decimal byte length of the field that follows the ':'. std::string encodeAssignmentRequest(const AssignmentRequest& req); -// Parse a wire string produced by encodeAssignmentRequest. std::nullopt on any -// malformed / truncated / trailing-garbage input (never UB, never a partial value) — -// the reader shell treats absence/malformed as "no pending request." Round-trips: -// decodeAssignmentRequest(encodeAssignmentRequest(x)) == x. +// std::nullopt on any malformed/truncated/trailing-garbage input (never UB, +// never a partial value); the reader treats that as "no pending request." +// Round-trips: decodeAssignmentRequest(encodeAssignmentRequest(x)) == x. // -// READER REQUIREMENT (instrument-side, S8 follow-up dispatch): after successfully -// decoding a request, the reader MUST verify that (bankId, sampleId) resolves to an -// existing sample before acting on it. An undo on the extension side rolls back the -// `banks` ext-state key (removing the sample) but cannot atomically clear the -// `assign_request` key if the write happened outside the undo block. Even with the -// undo-grouping fix (Major 2), the reader must guard against this: treat an -// unresolvable (bankId, sampleId) pair as a stale/no-op request and discard it -// silently, never crashing or selecting a nonexistent entry. +// Reader requirement: an undo can roll back the `banks` key without atomically +// clearing `assign_request`, so after decoding, the reader must verify +// (bankId, sampleId) still resolves to an existing sample and silently drop it +// otherwise — never crash or select a nonexistent entry. std::optional decodeAssignmentRequest(const std::string& wire); } // namespace reasampler::wire diff --git a/src/core/wire/bytes.h b/src/core/wire/bytes.h index 8b6c5fa..0350191 100644 --- a/src/core/wire/bytes.h +++ b/src/core/wire/bytes.h @@ -1,19 +1,12 @@ -// core/wire/bytes.h — the ONE little-endian byte codec (Q-W2v; audit T4-20). -// Pure, header-only: standard library only — NO REAPER, NO SWELL, NO VST3. +// core/wire/bytes.h — the ONE little-endian byte codec. Pure, header-only: +// standard library only — no REAPER, no SWELL, no VST3. // -// Five hand-rolled LE copies existed at the Q-W0 census (sample_map's -// putU32le/putU64le + ByteReader, capture_realtime's writeU32LE, capture_paths' -// readU32LE lambda, ingest's putU32 lambda, instrument_drop's appendU32LE). This -// template is the single survivor: compile-time dispatched, zero runtime cost, -// entirely off hot paths (serialization / file I/O only). The ComponentState -// codec (component_state_io) is its biggest consumer; the remaining hand-rolled -// copies rewire opportunistically in the waves that already open their files. -// -// Wire formats are FROZEN: putLE/putLE emit exactly the bytes the -// retired putU32le/putU64le emitted (LSB first, fixed width), and ByteReader -// preserves the latch-on-truncation contract (once a read runs past the end, -// ok latches false and every subsequent read yields zeros/empties — a truncated -// blob degrades to a partial parse, never out-of-bounds). +// component_state_io is the biggest consumer. Wire format is FROZEN: putLE +// emits fixed-width LSB-first bytes exactly as the hand-rolled copies it +// replaced did, and ByteReader preserves the latch-on-truncation contract — +// once a read runs past the end, ok latches false and every subsequent read +// yields zeros/empties, so a truncated blob degrades to a partial parse, +// never an out-of-bounds read. #pragma once @@ -50,11 +43,8 @@ inline double bitsToDouble(std::uint64_t bits) { return d; } -// A bounded little-endian reader over a byte blob. Every read is length-checked; -// once a read runs past the end the reader latches `ok=false` and yields zeros, -// so a truncated blob degrades to a partial/empty parse rather than reading out -// of bounds. (The class formerly private to sample_map.cpp, promoted here as the -// codec's tested primitive — T4-20.) +// A bounded little-endian reader over a byte blob (see the file header for the +// truncation-latch contract). struct ByteReader { const std::vector& bytes; std::size_t pos = 0; diff --git a/src/core/wire/ext_state_read.h b/src/core/wire/ext_state_read.h index 0f0ec75..2c9a9f0 100644 --- a/src/core/wire/ext_state_read.h +++ b/src/core/wire/ext_state_read.h @@ -1,29 +1,24 @@ #pragma once -// ext_state_read — the GetProjExtState GROW-LOOP retry policy (T2-04; rehomed to -// core/wire in Q-W6 — its consumers are the extension's persist/usage-scan shells -// AND the instrument's bridge, so it lives on the neutral wire seam rather than in -// the instrument-side bridge_marshal decode helper it started in). +// ext_state_read — the GetProjExtState grow-loop retry policy, shared by the +// extension's persist/usage-scan shells and the instrument's bridge so the +// retry/termination rules cannot drift between them. // -// GetProjExtState writes into a caller-supplied buffer with no documented -// query-the-size call, so a large value (bank blob, usage record) must be read by -// growing a buffer until the value fits strictly inside it. Three shells carried -// hand-rolled copies of that loop (persist's ext-state reads, usage_scan's -// prune-safety-adjacent record read, reaper_bridge's VST-side bank read); the ONE -// policy lives here so the retry/termination rules cannot drift. The fiddly part -// is the termination taxonomy, which each caller folds differently: +// GetProjExtState writes into a caller-supplied buffer with no query-the-size +// call, so a large value must be read by growing a buffer until it fits +// strictly inside it. Termination taxonomy (each caller folds differently): // // * Absent — the API returned <= 0 on some attempt: the key holds no value. // (persist -> "" empty bank; usage_scan / bridge -> nullopt) // * Complete — the written C string fits STRICTLY inside the buffer (size+1 < // cap), so it cannot have been clipped: `value` is the whole value. -// * Overflow — the value never fit under the 16 MB ceiling: it is unreadable -// WHOLE, which is NOT the same as absent. (persist warns on the -// console; usage_scan folds it to the prune fail-safe abort) +// * Overflow — the value never fit under the 16 MB ceiling: unreadable WHOLE, +// NOT the same as absent. (persist warns on console; usage_scan +// folds it to the prune fail-safe abort) // -// `read` is one GetProjExtState-shaped attempt: int read(char* buf, int cap), -// returning the API's int. A template, statically dispatched per call site — no -// virtual calls, no std::function (the §3 performance guardrail); the caller binds -// the project/namespace/key (or a resolved function pointer, VST side) in a lambda. +// `read` is one GetProjExtState-shaped attempt: int read(char* buf, int cap). +// Template, statically dispatched per call site — no virtual calls, no +// std::function (hot-path guardrail); the caller binds project/namespace/key +// in a lambda. #include #include @@ -54,7 +49,7 @@ GrowingExtStateRead readProjExtStateGrowing(ReadFn&& read) { result.status = GrowingExtStateRead::Status::Absent; return result; } - buf[static_cast(cap) - 1] = '\0'; // defensive: guard against a read() that fills the buffer without honoring NUL-termination within cap + buf[static_cast(cap) - 1] = '\0'; // guard a read() that ignores NUL-termination within cap std::string s(buf.data()); if (static_cast(s.size()) + 1 < cap) { result.status = GrowingExtStateRead::Status::Complete; diff --git a/src/core/wire/instrument_drop.cpp b/src/core/wire/instrument_drop.cpp index 78c1b0a..3732b32 100644 --- a/src/core/wire/instrument_drop.cpp +++ b/src/core/wire/instrument_drop.cpp @@ -1,14 +1,14 @@ // instrument_drop — pure implementation. See instrument_drop.h. -// NO REAPER / SWELL / VST3 SDK / vendor. Reuses sample_map's ComponentState serializer and -// the SDK-free UID macros (core/wire/reasampler_uid.h). +// No REAPER/SWELL/VST3 SDK/vendor. Reuses sample_map's ComponentState serializer +// and the SDK-free UID macros (reasampler_uid.h). #include "core/wire/instrument_drop.h" #include #include "core/wire/reasampler_uid.h" // REASAMPLER_ACTIVE_UID_* — the FROZEN, channel-selected class UID -#include "core/instrument/map/component_state_io.h" // ComponentState + serializeComponentState (the SHARED writer, Q-W2v codec split) -#include "core/wire/bytes.h" // putLE — the ONE LE byte codec (T4-20) +#include "core/instrument/map/component_state_io.h" // ComponentState + serializeComponentState (the SHARED writer) +#include "core/wire/bytes.h" // putLE — the ONE LE byte codec namespace reasampler::wire { @@ -18,8 +18,7 @@ using instrument::map::serializeComponentState; namespace { // The .vstpreset container stores its integers little-endian on disk (public.sdk -// vstpresetfile.cpp swaps only on big-endian hosts) — putLE (core/wire/bytes.h) is -// exactly that byte order; the former appendU32LE/appendU64LE copies are retired (T4-20). +// vstpresetfile.cpp swaps only on big-endian hosts) — putLE is exactly that byte order. void appendFourCC(std::vector& out, const char id[4]) { out.insert(out.end(), id, id + 4); @@ -28,9 +27,6 @@ void appendFourCC(std::vector& out, const char id[4]) { } // namespace std::string vstClassIdHex() { - // FUID::toString reduces to the four INLINE_UID words as "%08X" in order on BOTH byte - // layouts (see header contract), so rendering the macros directly is the platform-stable - // derivation of the string the .vstpreset header must carry. char buf[33]; std::snprintf(buf, sizeof(buf), "%08X%08X%08X%08X", static_cast(REASAMPLER_ACTIVE_UID_1), @@ -69,12 +65,8 @@ std::vector buildVstPresetBytes( } std::vector instrumentDropStateBytes(const std::string& sampleId) { - // The ONE fact the drop carries: this capture is the instance's selection. Everything - // else stays at the fresh-instance defaults (no zones, implicit channel mode, generation - // 0) — the same ComponentState a browser click would produce. The implicit mode means - // the GA auto-default will follow the loaded capture's channel count on first reload. - // serializeComponentState is the instrument's own writer (the single source of truth for - // the byte layout), so this is NOT a parallel encoder — it IS the instrument's encoder. + // Everything but selectionId stays at fresh-instance defaults (no zones, + // implicit channel mode, generation 0) — same as a browser click. ComponentState cs; cs.selectionId = sampleId; return serializeComponentState(cs); @@ -85,16 +77,7 @@ std::vector buildInstrumentDropPreset(const std::string& sampleId) } bool infoNamesFxHotspot(const std::string& info) { - // See the header contract. Prefix rule (S-GA-DropFX): "fx_" names the FX-chain / - // floating-FX windows; "tcp.fx" / "mcp.fx" prefixes name the TCP/MCP FX button and its - // sibling FX sub-elements (fxbyp/fxparm/fxlist...), tolerant of the SDK-documented "may - // append additional information". Bare "tcp"/"mcp" and non-FX sub-elements ("tcp.mute", - // "tcp.vol") must NOT trigger an instrument drop. - // - // EXCLUDE the embed-strip sub-element ("tcp.fxembed" / "mcp.fxembed"): that is the - // surface where a ReaSampler 9000 embed strip draws inside the TCP/MCP. Dropping a card - // there must NOT add a SECOND instance — the surface is the existing instance's own UI, - // not an FX-chain drop target. It starts with "tcp.fx" so it must be explicitly excluded. + // See the header contract for the prefix rule and the embed-strip exclusion. auto startsWith = [&info](const char* p) { return info.rfind(p, 0) == 0; }; if (startsWith("tcp.fxembed") || startsWith("mcp.fxembed")) return false; return startsWith("fx_") || startsWith("tcp.fx") || startsWith("mcp.fx"); diff --git a/src/core/wire/instrument_drop.h b/src/core/wire/instrument_drop.h index 25fec92..7363964 100644 --- a/src/core/wire/instrument_drop.h +++ b/src/core/wire/instrument_drop.h @@ -1,36 +1,25 @@ #pragma once -// instrument_drop — the PURE payload-construction core of S17 drop-and-load. +// instrument_drop — pure payload-construction core of drop-and-load: dropping a +// bank capture onto a track's FX surface instantiates ReaSampler 9000 on that +// track already playing that capture. No REAPER/SWELL/VST3 SDK/vendor includes +// (+ the pure sample_map it reuses and the SDK-free UID macros in +// reasampler_uid.h); unit-tested outside the DAW. // -// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO VST3 SDK, -// NO vendor/ includes. Standard library only (+ the pure sample_map it reuses and the -// SDK-free UID macros in core/wire/reasampler_uid.h). Unit-tested outside the DAW — the same -// "small pure builder + round-trip proof" pattern as assignment_request / provenance. +// Mechanism: after TrackFX_AddByName creates the instance, the extension +// writes a Steinberg-format .vstpreset file whose 'Comp' chunk is the +// instrument's own component state (capture pre-selected) and applies it via +// TrackFX_SetPreset — the SDK-documented path for VST3 plug-ins. // -// -- What it is (the S17 seam, extension side) -------------------------------- +// NOT TrackFX_SetNamedConfigParm("vst_chunk", ...): for VST3 that string is +// REAPER's own wrapper framing, not the raw IComponent::setState stream — +// writing raw component-state bytes there "succeeds" but the wrapper cannot +// apply the unframed blob, leaving the instance silently at defaults. The +// .vstpreset container is Steinberg-documented (public.sdk/source/vst/ +// vstpresetfile.cpp is the reference layout) and buildable byte-exactly. // -// S17 drops a bank capture onto a track's FX surface, which instantiates ReaSampler 9000 on -// that track ALREADY PLAYING that capture. The injection mechanism (S-GA-DropFX revision of -// PLAN.md §S17 mechanism (B)): after TrackFX_AddByName creates the instance, the extension -// writes a Steinberg-format .vstpreset file whose 'Comp' chunk is the instrument's own -// component state (the dragged capture pre-selected) and applies it via -// TrackFX_SetPreset(track, fx, ".vstpreset") -// which the SDK documents as accepting full .vstpreset paths for VST3 plug-ins. -// -// WHY NOT vst_chunk (the S-GA-DropFX diagnosis): TrackFX_SetNamedConfigParm's "vst_chunk" -// is "base64-encoded VST-specific chunk" — for a VST3 that is REAPER's OWN wrapper framing -// of the plugin state (the bytes REAPER round-trips into the RPP #include @@ -38,77 +27,45 @@ namespace reasampler::wire { -// The 32-char uppercase-hex class-ID string of THIS build's channel-active ReaSampler 9000 -// VST3 class UID — exactly what Steinberg::FUID::toString renders and what a .vstpreset -// header carries (public.sdk vstpresetfile: "ASCII-encoded FUID"). On both COM-compatible -// (Windows GUID byte order) and plain layouts, FUID::toString reduces to the four -// INLINE_UID uint32 words printed "%08X" in order, so this derivation is platform-stable. -// Sourced from the FROZEN macros in core/wire/reasampler_uid.h (the same constants the factory -// registers), channel-selected by the one REASAMPLER_CHANNEL_IS_BETA bit — a beta extension -// writes presets only the beta VST class accepts, preserving the S18 pairing invariant. +// The 32-char uppercase-hex class-ID string of this build's channel-active +// ReaSampler 9000 VST3 class UID — exactly what Steinberg::FUID::toString +// renders and what a .vstpreset header carries (the four INLINE_UID uint32 +// words printed "%08X" in order, platform-stable on both COM-compatible and +// plain layouts). Sourced from reasampler_uid.h, channel-selected — a beta +// extension writes presets only the beta VST class accepts. std::string vstClassIdHex(); -// Build a Steinberg VST3 preset file image (the bytes of a .vstpreset) carrying exactly one -// 'Comp' chunk = `componentState`, addressed to class `classIdHex32` (32 hex chars, see -// vstClassIdHex). Layout per public.sdk/source/vst/vstpresetfile.cpp, all integers -// little-endian on disk: -// [0] 'VST3' — header magic -// [4] int32 version = 1 -// [8] 32-char ASCII class ID +// Builds a Steinberg VST3 preset image with exactly one 'Comp' chunk = +// `componentState`, addressed to class `classIdHex32` (32 hex chars). Layout +// per public.sdk/source/vst/vstpresetfile.cpp, little-endian: +// [0] 'VST3' [4] int32 version=1 [8] 32-char class ID // [40] int64 chunk-list offset (= 48 + componentState.size()) -// [48] the component-state bytes — the one 'Comp' chunk's data -// then 'List', int32 entry count = 1, then the entry: 'Comp', int64 offset 48, int64 size. -// No 'Cont' chunk is written: the instrument is a SingleComponentEffect whose whole state is -// the component stream; a controller-state chunk is optional in the container format. -// Returns an empty vector when classIdHex32 is not exactly 32 chars (contract violation). +// [48] component-state bytes, then 'List' + entry count=1 + {'Comp', 48, size}. +// No 'Cont' chunk (a SingleComponentEffect's controller state is optional in +// the container format). Empty vector when classIdHex32 isn't 32 chars. std::vector buildVstPresetBytes(const std::string& classIdHex32, const std::vector& componentState); -// The drop payload: a .vstpreset image for the channel-active class whose component state is -// the instrument's default face with just `sampleId` picked — {selectionId = sampleId, no -// zones, mono, generation 0}, exactly what a fresh instance would hold after the user -// clicked that capture in the browser. The keymap builds under the product defaults (Gate + -// Preserve) from the bank's own S2 intrinsics, so the sample plays MIDI-triggered -// immediately (the S17 "loaded, selected, playable" verify). -// -// An EMPTY sampleId yields the empty-state preset ({"", no zones}) — a drop of nothing -// selects nothing (the S10 silent empty state); the shell guards against this upstream, but -// the pure contract is defined. -// -// Deterministic: the same sampleId always yields the same bytes. +// The drop payload: a .vstpreset for the channel-active class with just +// `sampleId` picked (no zones, mono, generation 0) — what a fresh instance +// would hold after a browser click. Empty sampleId yields the empty-state +// preset. Deterministic. std::vector buildInstrumentDropPreset(const std::string& sampleId); -// -- FX-drop-target classification (S-VIEW-BUG-1 / S-GA-DropFX) ---------------- -// -// Pure classifier for GetThingFromPoint's info string: is the point over a surface where an -// instrument drop should instantiate ReaSampler 9000 on the resolved track? This is string -// logic (no REAPER types), so it lives here and is unit-tested outside the DAW — the shell -// (instrument_drop_win) only supplies the info bytes GetThingFromPoint filled. -// -// The SDK (reaper_plugin_functions.h §GetThingFromPoint) documents "fx_chain"/"fx_N" for -// the FX-chain and floating-FX windows, and "tcp"/"mcp"-prefixed strings with sub-element -// tokens ("tcp.mute" is the doc's example) for track-panel hits — WITH the explicit warning -// that "future versions may append additional information". The FX-button sub-token itself -// is undocumented; the WALTER element family names the TCP/MCP FX surfaces "tcp.fx", -// "tcp.fxbyp", "tcp.fxparm", "tcp.fxembed", "mcp.fxlist", ... — all beginning "tcp.fx" / -// "mcp.fx". So the hotspot rule is PREFIX-based (S-GA-DropFX: the earlier exact-token match -// on "tcp.fx"/"mcp.fx" was too strict for appended info and sibling FX elements): -// * "fx_" prefix — the FX-chain and floating-FX windows -// * "tcp.fx" / "mcp.fx" prefix — the TCP/MCP FX button + sibling FX sub-elements -// EXCEPT "tcp.fxembed" / "mcp.fxembed" — the embed-strip surface where a ReaSampler 9000 -// instance draws inside the TCP/MCP. Dropping onto the existing instance's own UI must NOT -// add a second instance; the embed surface is explicitly excluded even though it starts -// with "tcp.fx". All other "tcp.fx*" / "mcp.fx*" tokens (fxbyp, fxparm, fxlist, ...) are -// hotspots — they are FX-chain controls, not a running instance's own surface. -// Bare "tcp"/"mcp" and non-FX sub-elements (e.g. "tcp.mute", "tcp.vol") are NOT hotspots. -// The exact live token over the FX button remains a DAW-only fact — confirm in REAPER (a -// deferred ReaScript around reaper.GetThingFromPoint(reaper.GetMousePosition()) prints it). +// Pure classifier for GetThingFromPoint's info string: is the point over a +// surface where an instrument drop should instantiate ReaSampler 9000? The +// SDK warns future versions may append information, so the rule is +// PREFIX-based: "fx_" (FX-chain/floating windows) or "tcp.fx"/"mcp.fx" (the +// TCP/MCP FX button + sibling elements) EXCEPT "tcp.fxembed"/"mcp.fxembed" — +// the embed-strip surface where an instance already draws; dropping there +// must not add a second instance. Bare "tcp"/"mcp" and non-FX sub-elements +// are not hotspots. The exact live token is DAW-only — confirm via +// reaper.GetThingFromPoint(reaper.GetMousePosition()) in ReaScript if unsure. bool infoNamesFxHotspot(const std::string& info); -// The raw component-state bytes the preset carries — exposed so the round-trip test can -// decode them back through the instrument's OWN reader (sample_map::deserializeComponentState) -// and assert the capture is selected, proving the preset feeds the instrument exactly what -// its setState expects. Not called by the shell (which uses the .vstpreset image). +// The raw component-state bytes the preset carries, exposed so the round-trip +// test can decode them back through sample_map::deserializeComponentState and +// assert the capture is selected. Not called by the shell. std::vector instrumentDropStateBytes(const std::string& sampleId); } // namespace reasampler::wire diff --git a/src/core/wire/reasampler_uid.h b/src/core/wire/reasampler_uid.h index 28e9c0d..93102e5 100644 --- a/src/core/wire/reasampler_uid.h +++ b/src/core/wire/reasampler_uid.h @@ -1,38 +1,31 @@ #pragma once -// reasampler_uid.h — the FOREVER-FROZEN VST3 class-UID constants, SDK-FREE. +// reasampler_uid.h — the FOREVER-FROZEN VST3 class-UID constants, SDK-free. // -// Split out of reasampler_vst.h (S-GA-DropFX) so the PURE extension side can derive the -// class-ID string a .vstpreset file carries (instrument_drop::vstClassIdHex) WITHOUT -// including the VST3 SDK: reasampler_vst.h needs Steinberg::FUID (SDK), but the UID VALUES -// are plain integer macros. This header owns the values + the channel selection; nothing -// else. reasampler_vst.h includes it to build the runtime FUID; instrument_drop includes it -// to render the 32-char hex string. ONE source of truth — the frozen constants are written -// exactly once, here. +// Split out of reasampler_vst.h so the pure extension side can derive the .vstpreset +// class-ID string (instrument_drop::vstClassIdHex) without pulling in the VST3 SDK. +// reasampler_vst.h builds the runtime FUID from these same macros; instrument_drop +// renders the hex string from them — one source of truth, so binary identity and +// preset-file identity cannot diverge. // -// A class UID is FOREVER-STABLE once shipped: a REAPER project that instantiates the -// instrument records the UID, so changing it orphans every saved instance. Minted once; -// do not regenerate. See reasampler_vst.h for the full channel-isolation story (S18). +// FOREVER-STABLE once shipped: a REAPER project that instantiates the instrument +// records the UID, so changing it orphans every saved instance. Never regenerate. #include "version_generated.h" // REASAMPLER_CHANNEL_IS_BETA — the one channel bit -// STABLE class UID (S-NAME-1). Minted at the S1 spike (2026-07-26), locked. FROZEN FOREVER. +// STABLE class UID. FROZEN FOREVER. #define REASAMPLER_PROC_UID_1 0x5E45A11E #define REASAMPLER_PROC_UID_2 0x9C7B4D6A #define REASAMPLER_PROC_UID_3 0xB1E3F208 #define REASAMPLER_PROC_UID_4 0x4A6C1D9F -// BETA class UID (S18). Minted once (2026-07-26), locked FROM THIS WAVE per Daniel's -// fast-track (fork S18-F1: mint now, not at first beta release). FROZEN FOREVER — the same -// permanent lock as the stable UID; do not regenerate even though no beta VST has shipped. +// BETA class UID. FROZEN FOREVER — locked even though no beta VST has shipped yet. #define REASAMPLER_PROC_UID_BETA_1 0xCCFFEB3A #define REASAMPLER_PROC_UID_BETA_2 0x4FF532A6 #define REASAMPLER_PROC_UID_BETA_3 0x9E181798 #define REASAMPLER_PROC_UID_BETA_4 0x4256955F -// The channel-selected UID macros — exactly one class UID per binary. The factory's -// INLINE_UID (compile-time brace init) and the runtime FUID in reasampler_vst.h both source -// these, as does the extension's vstClassIdHex (the .vstpreset class-ID string), so the -// binary identity and the preset-file identity cannot diverge. +// Channel-selected UID macros — exactly one class UID per binary. The factory, +// the runtime FUID, and vstClassIdHex all source these. #if REASAMPLER_CHANNEL_IS_BETA #define REASAMPLER_ACTIVE_UID_1 REASAMPLER_PROC_UID_BETA_1 #define REASAMPLER_ACTIVE_UID_2 REASAMPLER_PROC_UID_BETA_2 diff --git a/src/core/wire/sample_usage.cpp b/src/core/wire/sample_usage.cpp index 0489650..3257924 100644 --- a/src/core/wire/sample_usage.cpp +++ b/src/core/wire/sample_usage.cpp @@ -12,11 +12,6 @@ namespace { constexpr const char* kMagic = "rsusage1"; -// The shared core/wire codec (Q-W1, T2-01b) — one grammar across every -// ext-state seam. The former local fieldCount (10-digit cap) is subsumed by the -// codec's fieldSizeT (20-digit cap + overflow-guarded accumulate): every count -// the old cap accepted decodes identically, and any larger count is rejected by -// the count-vs-wire-size sanity bound at the call site below. using wire::putField; using Cursor = wire::Cursor; @@ -65,31 +60,20 @@ std::optional decodeUsageRecord(const std::string& wire) { UsagePublishPlan planUsagePublish(const std::optional& existing, const UsageRecord& mine) { UsagePublishPlan plan; - // The written form of "just mine": mine's identity + holds, unioned=false (the plan - // computes the flag; a sole-writer record is un-poisoned). UsageRecord cleanMine = mine; cleanMine.unioned = false; plan.wire = encodeUsageRecord(cleanMine); if (!existing || existing->empty()) { - // Fresh key — write mine. - return plan; + return plan; // fresh key — write mine } + const std::optional theirs = decodeUsageRecord(*existing); if (!theirs) { - // Undecodable existing value under MY key: corruption (a sibling sharing - // this key via copy always writes decodable records). REMINT rather than - // overwrite: writing mine over the corrupt key would clear the prune-side - // abort, but a same-key sibling B's holds would then be unprotected until - // B publishes again. Leaving the corrupt key in place keeps the prune-side - // abort firing (foldUsageRecords.abortPrune) so the window where B's holds - // might be unprotected can never resolve toward delete. Mine is published - // under the new key that remint produces. - // NOTE (>16 MB gap): readReasamplerExtState returning nullopt for a value - // larger than 16 MB is indistinguishable from "absent" at the publish site; - // that narrow case takes the fresh-write branch above rather than remint. - // Both outcomes are safe (fresh write is also correct for a truly absent key); - // the gap is documented in the header's fail-safe list. + // Corrupt value under my key: remint rather than overwrite. Overwriting + // would clear the prune-side abort currently protecting a same-key + // sibling's (possibly unprotected) holds; leaving the corrupt key in + // place keeps that abort firing until the sibling republishes. plan.remint = true; return plan; } @@ -98,21 +82,16 @@ UsagePublishPlan planUsagePublish(const std::optional& existing, !mine.ownerNonce.empty() && theirs->ownerNonce == mine.ownerNonce; if (nonceMatch && !theirs->unioned) { - // Exactly THIS incarnation wrote the key (the per-lifetime nonce is the exact - // ownership proof — a same-track sibling's byte-identical hold set can NOT pass - // this test, its nonce differs) AND no other writer has ever unioned into it, - // so the content is provably all mine. Clean replace: released holds drop. + // Exactly this incarnation wrote the key last and it was never unioned + // by another writer — content is provably all mine. if (plan.wire == *existing) plan.skipWrite = true; // idle reload tick return plan; } if (theirs->trackGuid == mine.trackGuid || (nonceMatch && theirs->unioned)) { - // A foreign writer on MY OWN track (a same-track copy-sibling, or my own - // last-session record — indistinguishable by construction), or a record I - // wrote last but that carries unioned holds from an earlier multi-writer - // merge. Either way no hold in it may be dropped by me — union, existing- - // first, de-duped, and the record is (or stays) POISONED unioned=true so no - // future nonce-matching write can clean-replace a sibling's holds away. + // Same-track sibling, my own last-session record, or an already-unioned + // record — no hold in it may be dropped. Union, existing-first, de-duped, + // poisoned unioned=true so a future clean replace can never drop it. UsageRecord merged; merged.trackGuid = mine.trackGuid; merged.ownerNonce = mine.ownerNonce; @@ -126,18 +105,16 @@ UsagePublishPlan planUsagePublish(const std::optional& existing, if (!dup) merged.holds.push_back(h); } if (theirs->unioned && merged.holds == theirs->holds) { - // Already poisoned and the union adds nothing — the write would flip only - // the ownerNonce. Skip the redundant ext-state churn. (A false->true - // unioned flip is NEVER skipped: it is the poison that protects the other - // writer's holds from the last writer's future clean replace.) + // Already poisoned and the union adds nothing -> the write would only + // flip ownerNonce; skip. A false->true unioned flip is NEVER skipped. plan.skipWrite = true; } plan.wire = encodeUsageRecord(merged); return plan; } - // Foreign value from ANOTHER track: this instance is a cross-track copy (or was - // moved). Take a fresh identity; never overwrite the other's record. + // Foreign value from another track: a cross-track copy or move. Fresh + // identity; never overwrite the other's record. plan.remint = true; return plan; } @@ -175,16 +152,11 @@ UsageFoldResult foldUsageRecords( records.reserve(decoded.size()); for (const std::optional& rec : decoded) { if (!rec) { - // A present-but-unreadable record: it may protect ANYTHING, so the prune - // must halt outright. Belt-and-braces: return the PROTECT-ALL set (all - // readable records' paths) so the fail-safe holds even under a future - // caller that forgets to check abortPrune before using heldPaths. The - // abort flag is still the authoritative signal; heldPaths is the - // maximum-protection fallback. + // Present-but-unreadable record: it may protect anything, so halt. + // Belt-and-braces: also return the protect-all set (every readable + // record's paths, bypassing the liveness filter) so the fail-safe + // holds even if a future caller forgets to check abortPrune first. result.abortPrune = true; - // Collect EVERY path from EVERY readable record, bypassing the liveness - // filter entirely (on abort the protected set is unknowable, so every - // decoded hold must be included regardless of track-guid membership). std::unordered_set seen; for (const std::optional& r : decoded) { if (!r) continue; @@ -214,19 +186,11 @@ bool identityMatches(const std::string& identity, const std::string& uidHexUpper const std::string& outputNameUpper) { if (identity.empty()) return false; const std::string up = toUpperAscii(identity); - // Primary: the 32-hex class UID embedded in REAPER's fx_ident rendering. Not - // guaranteed on every platform/REAPER build (byte-order of the rendered FUID vs - // REAPER's hex is unverified on Windows COM layout), hence the two name nets below - // — and the protect-all fold above them (see usageHeldPaths). + // Class-UID byte-order in fx_ident is unverified on Windows COM layout, + // hence the two name fallbacks below (see header for the protect-all net). if (!uidHexUpper.empty() && up.find(uidHexUpper) != std::string::npos) return true; - // The module filename base ("REASAMPLER_9000") — fx_ident carries the .vst3 module - // path, so this is the alternative that works in the common case (the display name - // "REASAMPLER 9000", space-separated, can never match the filename form). if (!outputNameUpper.empty() && up.find(outputNameUpper) != std::string::npos) return true; - // The factory display name — matches original_name / renamed-instance renderings. - // Beta-substring over-protect is deliberate (see the header note): stable needles - // are substrings of beta ones, widening protection only — never a delete. return !nameUpper.empty() && up.find(nameUpper) != std::string::npos; } diff --git a/src/core/wire/sample_usage.h b/src/core/wire/sample_usage.h index 8553bf0..a42e6f5 100644 --- a/src/core/wire/sample_usage.h +++ b/src/core/wire/sample_usage.h @@ -1,104 +1,66 @@ #pragma once -// sample_usage — the pure core of the pS-usage seam: ReaSampler 9000 instances count -// as USAGE for the prune. Each live instance PUBLISHES the captures it holds (its v10 -// SampleRefs — sample ids + project-relative paths) to a per-instance project ext-state -// key ("rsusage_", see ext_keys.h); the EXTENSION reads every usage record -// at prune-scan time, keeps only the records backed by a live ReaSampler 9000 FX -// instance, and folds the surviving paths into the prune's `referenced` set — so a file -// any live instance holds can never be an orphan and BANK_PRUNE_FOLDER can never -// delete it. +// sample_usage — pure core of the instance-usage wire: ReaSampler 9000 instances +// count as usage for the prune. Each live instance publishes the captures it +// holds to a per-instance ext-state key ("rsusage_"); the +// extension reads every record at prune-scan time, keeps only the ones backed +// by a live FX instance, and folds the surviving paths into the prune's +// `referenced` set — a file any live instance holds can never be an orphan. // -// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO VST3, NO SWELL, -// NO vendor/ includes. Standard library only. The mirror of assignment_request (the -// other VST<->extension ext-state wire): the wire format AND the two safety-critical -// decisions (what to write on publish, which records count at prune time) live here so -// they are provable without a DAW. The shells only move strings. +// No REAPER/VST3/SWELL/vendor includes. Mirror of assignment_request on the +// instrument->extension direction: the wire format and the two safety-critical +// decisions (what to write on publish, which records count at prune time) are +// pure and provable without a DAW; shells only move strings. // -// -- The data-ownership boundary (load-bearing) ------------------------------- +// The INSTRUMENT writes usage keys, the EXTENSION only reads them — the one +// sanctioned instrument->ext-state write. It does not weaken the +// read-only-bank invariant: the instrument publishes only its own +// per-instance key, never banks/view/tail/assign; the bridge's write entry +// point structurally accepts only "rsusage_"-prefixed keys. // -// The INSTRUMENT writes usage keys; the EXTENSION reads them. This is the ONE sanctioned -// instrument->ext-state write (Daniel's ruling: "if that means the VST writes to the -// bridge when it grabs a capture, so be it") and it does NOT weaken the read-only-BANK -// invariant: the instrument publishes its OWN usage under its OWN per-instance key, -// and never touches banks/view/tail/assign or any other extension-owned key. The bridge -// enforces this structurally — its write entry point accepts only "rsusage_"-prefixed keys. +// THE SAFETY PROPERTY (overrides every other consideration): every failure, +// ambiguity, or uncertainty here must fail-safe toward PROTECT. Over-protection +// (prune skips a reclaimable file, or refuses to run) is an accepted residual; +// under-protection (deleting a file an instance may still be playing) is a +// data-loss bug. Three folds enforce this: +// * sibling-collision -> UNION, never clean-replace over a foreign writer; +// * zero-identified -> records exist but no instance was identified live -> +// protect ALL records' paths (a matcher failure must +// never degrade toward delete); +// * unreadable record -> ABORT the prune entirely (a record we cannot read +// may protect anything; halting deletes nothing). // -// -- THE SAFETY PROPERTY (overrides every other consideration) ----------------- +// Liveness is decided extension-side at read time, not by teardown clearing +// (REAPER destroys the plugin instance when an FX goes offline, including +// Design View's CPU-park, so a terminate-time clear would strip a still-live +// instance's record) or challenge/response (a closed-editor instance could +// never answer a prune-time challenge). Publishing is eager instead (on load +// + every play-set change). // -// The un-prunable guarantee is a SAFETY property: every failure, ambiguity, or -// uncertainty in this seam must FAIL-SAFE toward PROTECT. Over-protection (prune skips a -// reclaimable file, or refuses to run at all) is an acceptable residual; under-protection -// (deleting a file an instance may still be playing) is a data-loss bug. Three fail-safe -// folds live in this pure module so they are provable without a DAW: -// * sibling-collision -> UNION, never clean-replace over a foreign writer (ownerNonce); -// * zero-identified -> records exist but NO instance was identified live -> protect -// ALL records' paths (an identity-matcher failure must never -// degrade toward delete); -// * unreadable record -> ABORT the prune entirely (foldUsageRecords.abortPrune — a -// record we cannot read may protect anything; halting deletes -// nothing). Residual: readReasamplerExtState returning nullopt -// for a >16 MB value is indistinguishable from "absent" at the -// publish site — that narrow case takes the fresh-write branch -// (not remint), noted here for completeness. +// The liveness rule (usageHeldPaths): a record counts iff its track still +// hosts >= 1 instance (offline included — a parked instance still protects +// its holds). A record with no resolvable track GUID counts while ANY +// instance exists (fail-safe fallback). Zero instances identified anywhere -> +// EVERY record's paths protected. // -// -- Liveness (no stale-key false-protect, no false-delete) -------------------- -// -// A usage record must protect exactly the captures of instances that still EXIST. Two -// rejected designs shape the rules below: -// * NO teardown clearing. The obvious "clear my key in terminate()" is WRONG here: -// REAPER destroys the plugin instance when an FX is set OFFLINE — including the -// extension's own Design View CPU-park (per-FX offline on inactive-mode tracks). A -// terminate-time clear would strip the record of an instance that still exists in -// the project, opening a prune-deletes-a-used-file window. Records are therefore -// never cleared by the instrument; staleness is resolved by the EXTENSION at read -// time against the live FX enumeration. -// * NO challenge/response. Instances only poll ext-state on the EDITOR's UI timer -// (pollBankSync); a closed-editor instance could never answer a prune-time -// challenge, and its holds would be false-deleted. Publishing is therefore EAGER -// (on load + on every play-set change via reloadInstrument), and liveness is -// decided extension-side. -// -// The liveness rule (usageHeldPaths): a record counts iff the track it was published -// from still exists AND that track still hosts at least one ReaSampler 9000 FX -// instance (offline FX included — chain enumeration is chunk-level, so a parked -// instance still protects its holds). A record whose track GUID could not be resolved -// at publish time (empty) counts while ANY ReaSampler 9000 instance exists in the -// project — the fail-safe fallback. And the identity-failure net: when records exist -// but ZERO instances were identified live anywhere, EVERY record's paths are protected -// (see the safety property above — indistinguishable from a matcher failure, so it may -// never resolve toward delete). Residuals: a deleted instance whose track still hosts a -// sibling 9000 keeps its record alive, and a project whose instances were all deleted -// keeps its leftover records protecting until an instance is identified again — both -// false-PROTECT only, bounded, documented, accepted. -// -// -- Identity & the copy problem (planUsagePublish) ----------------------------- -// -// The publishing key is a minted per-instance GUID persisted in ComponentState (v11). -// A persisted id is inherently COPYABLE (FX copy / track duplication clones component -// state byte-for-byte), so two live instances can wake up sharing one key. Worse, two -// same-track copies converge on byte-identical wires, so "existing == what I last -// wrote" is NOT a sound ownership test — a sibling's byte-identical write would pass -// it, and a later clean replace would silently drop the sibling's holds (the delete -// direction). TWO in-wire facts close this: -// * ownerNonce — a per-LIFETIME nonce minted fresh in memory each instance lifetime, -// NEVER persisted (a persisted nonce would clone with the state, recreating the -// ambiguity). Proves "exactly this incarnation wrote the key last". -// * unioned — a STICKY multi-writer poison flag. "I wrote the key last" does NOT -// imply "the key contains only my holds": after I union a sibling's holds under my -// own nonce, a later nonce-matching clean replace would drop them. So the first -// union sets unioned=true in the wire, and a unioned record REFUSES clean replace -// forever — every subsequent write is a union (holds only accumulate). Over-protect -// residual, accepted; a solo never-restarted instance keeps clean-replace -// semantics, and a remint starts a fresh un-poisoned key. -// The publish plan resolves every collision in the fail-safe direction: -// * existing ownerNonce == mine AND not unioned -> clean replace (sole writer, -// provably my content; holds the instance released genuinely drop). -// * same track with a foreign nonce, OR unioned -> UNION of holds, written with -// unioned=true (a same-track sibling, my own last-session record, or a -// multi-writer key; nothing may be dropped — over-protects, never under-protects). -// * foreign nonce, DIFFERENT track, not unioned-by-me -> RE-MINT (a cross-track copy -// or move; the newcomer takes a fresh identity and leaves the original's record -// untouched; a moved-away original's old record dies by the liveness rule). +// Identity & the copy problem (planUsagePublish): the publishing key is a +// per-instance GUID persisted in ComponentState — inherently copyable (FX +// copy / track duplication clones it byte-for-byte), so two live instances can +// share one key, and same-track copies converge on byte-identical wires, so +// "existing == what I last wrote" is not a sound ownership test. Two in-wire +// facts close this: +// * ownerNonce — a per-lifetime nonce, minted fresh in memory, NEVER +// persisted (a persisted nonce would clone with the state). Proves +// "exactly this incarnation wrote the key last." +// * unioned — a sticky poison flag: once a sibling's holds are unioned in, +// the record refuses clean replace forever (every subsequent write unions, +// holds only accumulate) — over-protect residual, accepted. +// Publish resolution, always leaning over-protect: +// * ownerNonce matches mine AND not unioned -> clean replace (sole writer; +// released holds drop). +// * same track with a foreign nonce, OR unioned -> UNION, written unioned=true. +// * foreign nonce, different track -> RE-MINT under a fresh key; the +// original's record is untouched and dies later by the liveness rule if +// abandoned. #include #include @@ -119,14 +81,10 @@ struct UsageHold { } }; -// One instance's published usage: the REAPER track GUID it was hosted on at publish -// time ("{...}" canonical form; empty when the host context could not resolve one), the -// writing incarnation's per-LIFETIME ownerNonce (the exact "did I write this?" ownership -// discriminator — see the copy-problem note above; never persisted in ComponentState), -// the sticky multi-writer `unioned` poison flag (once true, clean replace is refused -// forever — see the note above), plus every capture it holds. The record is -// self-contained — the extension needs nothing from the instance beyond this value and -// the live FX enumeration. +// One instance's published usage: the track GUID it was hosted on at publish +// time (empty if unresolvable), the writing incarnation's ownerNonce, the +// sticky `unioned` poison flag, plus every capture it holds — self-contained, +// the extension needs nothing beyond this value and the live FX enumeration. struct UsageRecord { std::string trackGuid; std::string ownerNonce; @@ -139,29 +97,22 @@ struct UsageRecord { } }; -// Encode a usage record to the wire string. Length-prefixed fields behind a magic tag -// ("rsusage1"), the same idiom as assignment_request / provenance, so arbitrary bytes -// in a GUID or path round-trip whole. Deterministic. -// -// FORMAT: "rsusage1" ':' ':' ':' -// ':' then per hold: ':' ':' +// Length-prefixed fields behind a magic tag ("rsusage1"), same idiom as +// assignment_request, so arbitrary bytes in a GUID or path round-trip whole. +// "rsusage1" ':' ':' ':' +// ':' then per hold: ':' ':' std::string encodeUsageRecord(const UsageRecord& rec); -// Parse a wire string produced by encodeUsageRecord. std::nullopt on malformed / -// truncated / trailing-garbage input (never UB, never a partial value). The prune scan -// treats an undecodable record as UNREADABLE and ABORTS (foldUsageRecords) — it must -// never proceed with protection it cannot read. +// std::nullopt on malformed/truncated/trailing-garbage input (never UB, never +// a partial value). The prune scan treats an undecodable record as unreadable +// and aborts (foldUsageRecords) rather than proceed with protection it cannot read. std::optional decodeUsageRecord(const std::string& wire); -// The publish decision computed BEFORE a write (see the identity note above). -// * remint — true when the existing key value belongs to a live foreign instance -// on another track: the caller must mint a fresh instance GUID and -// write under the NEW key, leaving the existing record untouched. -// * skipWrite — true when the write would change nothing that matters: byte-identical -// to the existing value (idle reload tick), or a union over an -// ALREADY-unioned record that adds no holds (the write would flip only -// the ownerNonce — redundant ext-state churn, skipped; a false->true -// unioned flip is never skipped, it is the multi-writer poison). +// The publish decision computed before a write. +// * remint — the existing key belongs to a live foreign instance on +// another track: mint a fresh instance GUID, write under it. +// * skipWrite — the write would change nothing that matters (byte-identical, +// or a union over an already-unioned record adding no holds). // * wire — the encoded value to write (mine, or the same-track union). struct UsagePublishPlan { bool remint = false; @@ -169,57 +120,32 @@ struct UsagePublishPlan { std::string wire; }; -// Decide what to write for `mine` given the key's current value. `mine.ownerNonce` is -// THIS lifetime's nonce; `mine.unioned` is ignored (the plan computes the written -// flag). Branches, in order: -// * existing absent/empty -> write mine (unioned=false — sole known writer). -// * existing undecodable -> REMINT (mine, unioned=false, under a fresh key) -// rather than overwriting the corrupt key: overwriting would clear the prune-side -// abort, leaving a same-key sibling's holds unprotected until it republishes. -// Leaving the corrupt key in place keeps the prune-side abort (foldUsageRecords) -// firing so no delete-ward window opens. The sibling writes its own decodable -// record on the next publish tick; the corrupt key is eventually evicted once no -// live instance references it. Narrow gap: a >16 MB value reads back as nullopt -// (indistinguishable from absent), so it takes the fresh-write branch rather than -// remint — both outcomes are safe; the gap is noted in the header's fail-safe list. -// * nonce match AND !unioned -> clean replace (sole writer, provably my content; -// released holds drop); skipWrite when -// byte-identical (idle reload tick). -// * same track OR unioned -> union(existing.holds, mine.holds), existing-first, -// de-duped, written with unioned=TRUE under my -// nonce — a sibling's holds are NEVER dropped. The -// false->true unioned flip is ALWAYS written (it is -// the poison that blocks the last writer's future -// clean replace); skipWrite only when the existing -// record is already unioned AND the union adds no -// holds (the write would change nonce only). -// * else (foreign, other track) -> remint = true, write mine (fresh un-poisoned key). +// Decide what to write for `mine` given the key's current value, in order: +// * absent/empty -> write mine (unioned=false). +// * undecodable -> REMINT under a fresh key rather than overwrite +// the corrupt value — overwriting would silently clear the prune-side abort +// that is currently protecting a same-key sibling's unreadable holds. +// * nonce match, !unioned -> clean replace (released holds drop). +// * same track, or unioned -> union(existing, mine), written unioned=true — +// a sibling's holds are never dropped. +// * foreign nonce, other track -> remint (fresh un-poisoned key). UsagePublishPlan planUsagePublish(const std::optional& existing, const UsageRecord& mine); -// The prune-side liveness fold: every project-relative path held by a LIVE instance, -// de-duped, in (record, hold) input order. A record counts iff -// * its trackGuid is non-empty and present in `liveTrackGuids` (a track that still -// exists AND still hosts >= 1 ReaSampler 9000 FX — the caller's enumeration), OR -// * its trackGuid is empty and `anyInstanceLive` is true (the fail-safe fallback for -// a record published without a resolvable track context). -// FAIL-SAFE NET (the safety property): when `records` is non-empty and -// `anyInstanceLive` is false — records exist but NOT ONE instance was identified -// anywhere — EVERY record's paths are returned (protect-all). Zero identified with -// records present is indistinguishable from an identity-matcher failure, and a matcher -// failure must never resolve toward delete. (Residual: leftover records in a project -// whose instances were all genuinely deleted keep protecting — false-PROTECT only.) -// Holds with an empty relativePath are skipped (nothing to protect). +// The prune-side liveness fold: every project-relative path held by a live +// instance, de-duped, in (record, hold) order. A record counts iff its +// trackGuid is present in `liveTrackGuids`, or its trackGuid is empty and +// `anyInstanceLive` is true. FAIL-SAFE NET: when `records` is non-empty and +// `anyInstanceLive` is false, EVERY record's paths are returned (protect-all; +// see the safety property above). Holds with an empty relativePath are skipped. std::vector usageHeldPaths( const std::vector& records, const std::unordered_set& liveTrackGuids, bool anyInstanceLive); -// The prune-side entry fold over RAW read/decode results, one element per enumerated -// rsusage_* key: nullopt = the key was present but could not be read or decoded -// (oversized ext-state read, truncation, corruption). ANY nullopt sets abortPrune — -// the prune must HALT and delete nothing (an unreadable record may protect anything; -// proceeding with degraded protection is the delete direction). Otherwise delegates to +// The prune-side entry fold over raw read/decode results, one element per +// enumerated rsusage_* key: nullopt = present but unreadable/undecodable. ANY +// nullopt sets abortPrune (halt, delete nothing); otherwise delegates to // usageHeldPaths (including its protect-all net). struct UsageFoldResult { bool abortPrune = false; @@ -230,20 +156,15 @@ UsageFoldResult foldUsageRecords( const std::unordered_set& liveTrackGuids, bool anyInstanceLive); -// FX-identity match for the live-instance enumeration (pure so the matcher itself is -// testable; the shell only supplies REAPER's identity strings). `identity` is the value -// of an FX's "fx_ident" or "original_name" named-config parm; the three needles are the -// UPPERCASED channel constants: -// * uidHexUpper — the 32-hex VST3 class UID (instrument_drop::vstClassIdHex), -// * nameUpper — the factory display name ("REASAMPLER 9000"), -// * outputNameUpper— the .vst3 module filename base ("REASAMPLER_9000") — the form -// fx_ident is guaranteed to embed (it carries the module path), -// which the space-separated display name can never match. -// Substring, case-insensitive. NOTE the deliberate beta-substring over-protect: the -// stable needles are substrings of the beta ones ("REASAMPLER 9000" ⊂ "REASAMPLER 9000 -// BETA", "REASAMPLER_9000" ⊂ "REASAMPLER_9000_BETA"), so a stable extension scanning a -// project with beta instances matches them too — a WIDER protected set only (fail-safe; -// it can never cause a delete). +// FX-identity match for the live-instance enumeration (pure so the matcher is +// testable; the shell supplies REAPER's identity strings). `identity` is an +// FX's "fx_ident" or "original_name" parm; the needles are the UPPERCASED +// channel constants — uidHexUpper (32-hex class UID), nameUpper (factory +// display name), outputNameUpper (.vst3 filename base, the form fx_ident is +// guaranteed to embed). Substring, case-insensitive. Deliberate beta-substring +// over-protect: stable needles are substrings of the beta ones, so a stable +// extension matches beta instances too — a wider protected set only, never a +// delete risk. bool identityMatches(const std::string& identity, const std::string& uidHexUpper, const std::string& nameUpper, const std::string& outputNameUpper); diff --git a/src/core/wire/wire.cpp b/src/core/wire/wire.cpp index 80c08b4..f0dd2a6 100644 --- a/src/core/wire/wire.cpp +++ b/src/core/wire/wire.cpp @@ -1,7 +1,5 @@ -// core/wire implementation — see wire.h. The bodies are the hardened -// assignment_request / sample_usage / provenance (post Q-W0 T2-01a backport) -// cursor, unified; any behavioral change here changes every ext-state wire -// seam at once. +// core/wire implementation — see wire.h. Any behavioral change here changes +// every ext-state wire seam at once. #include "core/wire/wire.h" diff --git a/src/core/wire/wire.h b/src/core/wire/wire.h index 9cdde10..608a284 100644 --- a/src/core/wire/wire.h +++ b/src/core/wire/wire.h @@ -1,24 +1,16 @@ -// core/wire — the ONE length-prefixed ext-state wire codec (Q-W1; audit -// T2-01(b)). Pure: standard library only — NO REAPER, NO SWELL, NO VST3. -// -// The `':'` field grammar ("one grammar across every -// ext-state seam") was previously implemented as three near-identical -// putField + Cursor copies (provenance / assignment_request / sample_usage) -// plus a fourth guarded decimal accumulate (bank_sync::parseBankGeneration) — -// and the copies drifted on the hardening. This is the single survivor, -// carrying the FULL hardening everywhere: +// core/wire — the ONE length-prefixed ext-state wire codec, `':' +// `, shared across every ext-state seam. Pure: standard library only — +// no REAPER, no SWELL, no VST3. Hardening carried everywhere: // - length digit-run capped at 20 (SIZE_MAX's decimal width) so a crafted // digit run cannot accumulate past SIZE_MAX via repeated multiply; // - overflow guard on every accumulate (multiply+add checked BEFORE applied); // - subtraction-first bounds check so a huge len cannot wrap `start + len`; // - fieldInt/fieldInt64 parse sign+digits manually with an INT64 overflow -// guard and an int range check — an out-of-range field FAILS the parse -// (closing the strtol errno/range gap the provenance copy carried). +// guard and an int range check — an out-of-range field FAILS the parse. // -// Wire formats on disk / ext-state are FROZEN: encode is byte-identical to the -// pre-collapse writers (std::to_string length + ':' + bytes), decode is -// tolerant-identical for every value a house writer can emit. "Never UB, never -// a partial value" is the parse-integrity promise. +// Wire format is FROZEN: encode is byte-identical to the writers this +// replaced (std::to_string length + ':' + bytes); decode never UB, never a +// partial value. #pragma once @@ -31,10 +23,9 @@ namespace reasampler::wire { // Append one length-prefixed field: ':' void putField(std::string& out, const std::string& field); -// Whole-string, non-negative decimal parse WITHOUT exceptions or locale -// surprises (the bank_sync generation-stamp core). False on empty, any -// non-digit (incl. a leading '+'/'-'), or overflow past INT64_MAX; the -// accumulate is overflow-guarded so a pathologically long digit run can never +// Whole-string, non-negative decimal parse without exceptions or locale +// surprises. False on empty, any non-digit (incl. leading '+'/'-'), or +// overflow past INT64_MAX; overflow-guarded so a long digit run can never // wrap into a bogus small value. bool parseUnsignedDecimal(const std::string& s, std::int64_t& out); @@ -51,30 +42,24 @@ public: // Consumes an exact literal at the cursor (the magic tag). Fails if absent. bool literal(const char* lit); - // Reads one length-prefixed field into `out`. Fails on a missing ':', an - // empty or non-numeric length, a length that would overflow SIZE_MAX, or a - // length that runs past the end. + // Fails on a missing ':', an empty/non-numeric length, an overflow past + // SIZE_MAX, or a length running past the end. bool field(std::string& out); - // Length-prefixed signed 64-bit decimal (optional leading '-'). Digit run - // capped at 19 (INT64_MAX's decimal width); overflow fails the parse. A - // 20-digit negative (only INT64_MIN itself) is conservatively rejected — + // Length-prefixed signed 64-bit decimal. Digit run capped at 19; a + // 20-digit negative (only INT64_MIN) is conservatively rejected too — // house writers emit generation timestamps and small enums, never that. bool fieldInt64(std::int64_t& out); - // fieldInt64 narrowed to int; a value outside [INT_MIN, INT_MAX] FAILS the - // parse (the fixed form of the provenance copy's silent strtol narrowing). + // fieldInt64 narrowed to int; out-of-[INT_MIN, INT_MAX] FAILS the parse. bool fieldInt(int& out); - // Length-prefixed unsigned decimal (element counts). Digit run capped at - // 20; overflow-guarded accumulate. Callers still apply their own - // count-vs-wire-size sanity bound BEFORE any reserve() on the result. + // Length-prefixed unsigned decimal (element counts). Callers still apply + // their own count-vs-wire-size sanity bound BEFORE any reserve(). bool fieldSizeT(std::size_t& out); - // Length-prefixed %.17g double. Full-token strtod; trailing bytes fail. - // Deliberately NO errno/ERANGE rejection: the writers emit %.17g of live - // doubles (incl. "inf"), and those must decode back — same accept set as - // every prior copy. + // Length-prefixed %.17g double. Deliberately no errno/ERANGE rejection: + // writers emit %.17g of live doubles (incl. "inf"), which must decode back. bool fieldDouble(double& out); private: diff --git a/src/ext_keys.h b/src/ext_keys.h index 6064c4a..08eaef5 100644 --- a/src/ext_keys.h +++ b/src/ext_keys.h @@ -1,36 +1,27 @@ #pragma once // ext_keys — the SINGLE SOURCE OF TRUTH for the "reasampler" project ext-state // namespace + key names, shared by the extension (writer, via shell/persist) and the -// VST3 instrument (reader, via the bridge). Both sides include this header so the -// wire contract cannot drift between the two artifacts (the S4 reviewer flagged the -// spike's duplicated constants as a drift risk). +// VST3 instrument (reader, via the bridge), so the wire contract cannot drift +// between the two artifacts. // -// PURE HEADER: NO REAPER types, NO VST3 types, NO SWELL, NO vendor/ includes. The key -// spellings are string constants; the NAMESPACE is channel-derived (Phase V, V4) so it -// delegates to the pure app_version module (also REAPER-free / VST3-free). Both the -// REAPER-facing persist shell and the SDK-facing VST bridge include this without pulling -// either SDK. +// PURE HEADER: NO REAPER/VST3/SWELL/vendor types. Key spellings are string +// constants; the namespace is channel-derived (delegates to app_version, also SDK-free). // // FOREVER-STABLE once shipped: these strings key every already-saved project's -// stored state. Changing any of them orphans that state. See shell/persist/ext_state_io.h for the -// per-key retirement / migration semantics — this header only owns the spellings. +// stored state. Changing any of them orphans that state. See +// shell/persist/ext_state_io.h for per-key retirement/migration semantics. #include "core/version/app_version.h" namespace reasampler { -// The ext-state namespace all ReaSampler project state is stored under. CHANNEL-DERIVED -// (Phase V, V4): delegates to the ONE app_version symbol so the extension (writer) and the -// VST3 instrument (reader) resolve the SAME namespace per channel — "reasampler" on stable, -// "reasampler_beta" on the isolated beta build. An accessor (not a constexpr literal) -// because the value is fixed by the channel bit at build time. This is the wire-contract -// reconciliation between S4 (shared ext_keys) and V4 (channel-isolated namespace): without -// it a beta instrument would read the stable namespace and see empty state. +// Channel-derived so the extension (writer) and the VST3 instrument (reader) +// resolve the SAME namespace per channel ("reasampler" / "reasampler_beta") — +// without this a beta instrument would read the stable namespace and see nothing. inline const char* kProjExtNamespace() { return version::extStateNamespace().c_str(); } -// The multi-bank key: the whole serialized BankBook (pool + named banks). This is -// the key the VST3 instrument reads to see the live bank (read-only, S4). ext_state_io -// documents its authority + the legacy-key migration around it. +// The whole serialized BankBook (pool + named banks) — read-only by the VST3 +// instrument. ext_state_io documents the legacy-key migration around it. inline constexpr const char* kProjExtBanksKey = "banks"; // The retired legacy single-bank key (read once on load to migrate into the pool). @@ -45,48 +36,34 @@ inline constexpr const char* kProjExtTailKey = "tail_setting"; // The per-project minted-GUID identity key. inline constexpr const char* kProjExtGuidKey = "project_guid"; -// The S9 BANK-GENERATION key. The EXTENSION stamps a monotonic decimal counter here that it -// bumps on every bank-content mutation that changes what a live instance would PLAY (capture -// add, re-capture-in-place, sample remove, move/copy affecting banks, ingest import). The VST3 -// instrument READS it off the audio thread on a UI-timer cadence and, when the value differs -// from what it last saw, calls reloadInstrument() so a recapture/ingest refreshes playing -// instances hands-free (the S9 change-detection trigger). WIRE-SHARED (instrument reads it); -// the instrument never WRITES it (the extension owns it, same read-only-over-bank rule as the -// assignment request). Additive to the persist blob — an absent stamp reads as generation 0 -// (a pre-S9 project), and the first bump (>= 1) then reads as a change. FOREVER-STABLE once -// shipped: changing this spelling resets every already-shipped instance's change-detection -// baseline (a one-time spurious reload), so it is fixed like every sibling key. +// The EXTENSION stamps a monotonic counter here, bumped on every bank-content +// mutation that changes what a live instance would PLAY. The VST3 instrument reads +// it on a UI-timer cadence and calls reloadInstrument() on a change; it never +// writes this key. Additive: an absent stamp reads as generation 0 (pre-existing +// projects). FOREVER-STABLE — changing the spelling resets every shipped instance's +// change-detection baseline (a one-time spurious reload). inline constexpr const char* kProjExtBankGenKey = "bank_generation"; -// The S8 ingest ASSIGNMENT-REQUEST key. The EXTENSION writes an assignment request here -// after an ingest-with-assign (arrange capture / Media-Explorer import / drop-onto-panel): -// "the active sampler instance should now play THIS sample." The value is the pure -// assignment_request wire format ("rsassign1" + bankId + sampleId + generation) — see -// assignment_request.h for the exact grammar. WIRE-SHARED because the VST3 instrument -// READS it (in a later dispatch, S8 instrument-side follow-up) to update its own selection -// and reload; the instrument never WRITES it (the extension writing its own namespace does -// not violate the instrument's read-only-over-the-bank rule). FOREVER-STABLE once shipped: -// changing this spelling strands any pending request an already-shipped instrument watches. +// The EXTENSION writes an assignment request here after an ingest-with-assign: +// "the active sampler instance should now play THIS sample." Value is the pure +// assignment_request wire format ("rsassign1" + bankId + sampleId + generation). +// The instrument reads it to update its own selection and reload; it never writes +// it. FOREVER-STABLE — changing the spelling strands any pending request an +// already-shipped instrument watches. inline constexpr const char* kProjExtAssignKey = "assign_request"; -// The pS-usage PER-INSTANCE USAGE-RECORD key prefix. The INSTRUMENT writes one key per -// instance — "rsusage_" — carrying the sample_usage wire record of every -// capture that instance holds; the EXTENSION enumerates the prefix at prune-scan time -// and folds live instances' holds into the prune's `referenced` set so a held capture -// can never be pruned. This is the ONE sanctioned instrument-side ext-state write -// (Daniel's ruling — the VST publishes its OWN usage; it never mutates banks/view/ -// tail/assign, and the bridge's write entry point structurally accepts only this -// prefix). WIRE-SHARED in the write->read direction the other keys reverse. The "rs" -// qualifier is deliberate: a future key that happens to start with "usage_" must never -// be swept into the FX-liveness fold (whose abort-on-unreadable rule would then halt -// every prune), so the prefix is namespaced like the wire magics (rsusage1/rsassign1). -// FOREVER-STABLE once shipped: changing the prefix strands every saved project's usage -// records (prune falls back to bank-references-only until instances republish — -// graceful, but the instance-hold protection lapses for stale-saved projects). +// The INSTRUMENT writes one key per instance — "rsusage_" — carrying +// the sample_usage wire record of every capture that instance holds; the EXTENSION +// enumerates the prefix at prune-scan time so a held capture can never be pruned. +// This is the ONE sanctioned instrument-side ext-state write (it never mutates +// banks/view/tail/assign; the bridge's write entry point structurally accepts only +// this prefix). The "rs" qualifier keeps a future "usage_*"-prefixed key from being +// swept into the FX-liveness fold. FOREVER-STABLE — changing the prefix strands +// every saved project's usage records (prune falls back to bank-references-only +// until instances republish). inline constexpr const char* kProjExtUsageKeyPrefix = "rsusage_"; -// The full per-instance usage key for a minted instance GUID (the one composition -// point, shared by the instrument's writer and the extension's enumerator). +// Shared by the instrument's writer and the extension's enumerator. inline std::string usageKeyFor(const std::string& instanceGuid) { return std::string(kProjExtUsageKeyPrefix) + instanceGuid; } diff --git a/src/ingest.cpp b/src/ingest.cpp index 43e288c..6837700 100644 --- a/src/ingest.cpp +++ b/src/ingest.cpp @@ -1,9 +1,6 @@ -// ingest.cpp — the S8 "ingest through the bank" shell (extension side). See ingest.h. -// -// Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.h WITHOUT -// REAPERAPI_IMPLEMENT — main.cpp owns the API pointers; here they are extern -// (CLAUDE.md §contract). REAPER-facing, DAW-verified; the pure serialization it drives -// (assignment_request) is CTest-tested. +// ingest.cpp — see ingest.h. main.cpp owns the API pointers; this TU gets them extern. +// REAPER-facing, DAW-verified; the pure serialization it drives (assignment_request) +// is CTest-tested. #include "ingest.h" @@ -21,7 +18,7 @@ #include "core/model/bank_model.h" // Sample, AddResult, findByHash #include "shell/panel/panel_input.h" // bankPanelRefresh #include "core/capture/capture_paths.h" // deriveBankPaths / projectDirOfRpp -#include "core/util/file_bytes.h" // shared whole-file loader (Q-W1, T2-03) +#include "core/util/file_bytes.h" // shared whole-file loader #include "core/wire/instrument_drop.h" // pure buildInstrumentDropPreset (.vstpreset image for a sampleId) #include "shell/actions/instrument_drop_win.h" // shell loadInstrumentOntoTrack (FX add+apply, no own undo block) #include "shell/persist/session.h" // ReaSamplerSession @@ -46,8 +43,6 @@ namespace reasampler { -// Real-namespace-home using-declarations (Q-W6: the interim core/namespaces.h shim -// is retired; each symbol names its Q-W1 home explicitly). using capture::BankPaths; using capture::buildFloat32Wav; using capture::deriveBankPaths; @@ -64,42 +59,31 @@ using wire::encodeAssignmentRequest; namespace { -// The live session the ingest paths mutate. Set once by ingestRegisterActions and read by -// every ingest body. Not owned here (main.cpp owns g_session). +// Not owned here (main.cpp owns g_session). ReaSamplerSession* g_session = nullptr; -// FOREVER-STABLE ingest action-id SUFFIX (Phase V, V4). The channel prefix is prepended at -// register via channelCommandId; NEVER change a shipped suffix. Only the Media-Explorer -// import registers here — the arrange capture+assign action lives in the capture family in -// main.cpp (it reuses the capture render machinery there), and the drop path is a panel -// callback (ingestDroppedFiles), not a bindable action. +// FOREVER-STABLE suffix — NEVER change after ship. Only the Media-Explorer import +// registers here — the arrange capture+assign action lives in the capture family in +// main.cpp, and the drop path is a panel callback (ingestDroppedFiles), not a +// bindable action. constexpr const char* kIdImportMediaExplorer = "INGEST_IMPORT_MEDIA_EXPLORER"; int g_cmdImportMediaExplorer = 0; gaccel_register_t g_accelImportMediaExplorer{}; -// Durable store of the composed, channel-qualified command-id + label strings. Two scalar -// std::string globals (one action); their c_str() pointers are handed to REAPER at register -// and re-presented at unregister, so these strings must not be mutated after registration. -// Populated once by ingestRegisterActions; stable for the extension lifetime. +// c_str() pointers are handed to REAPER at register and re-presented at unregister, +// so these strings must not be mutated after registration. std::string g_idImportStr; std::string g_labelImportStr; -// --- Project directory -------------------------------------------------------- - -// The current project's directory (parent of its .rpp), forward-slashed, no trailing -// slash — the M4 convention (projectDirOfRpp). Empty for an unsaved/no-active project, -// which makes the import refuse to place a file (no default-location fallback — the -// relative-paths invariant). Read-only. +// Forward-slashed, no trailing slash. Empty for an unsaved/no-active project, which +// makes the import refuse to place a file (relative-paths invariant, no fallback). std::string currentProjectDir() { std::vector buf(4096, '\0'); EnumProjects(-1, buf.data(), static_cast(buf.size())); return projectDirOfRpp(std::string(buf.data())); } -// Whole-file reads (source read + bank-copy validate/hash) go through the shared -// core/util readFileBytes (Q-W1, T2-03): empty on any failure (missing / unreadable). - // Writes a byte buffer to a file. Returns true on success. The caller is responsible for // ensuring the directory exists before calling. bool writeFileBytes(const std::string& path, const std::vector& bytes) { @@ -110,17 +94,13 @@ bool writeFileBytes(const std::string& path, const std::vector& by return f.good(); } -// The 32f WAV build itself lives in the pure wav_codec module (Q-W3, audit §4e / -// T4-10 — one owner of the RIFF layout, CTest-covered): buildFloat32Wav takes the -// interleaved ReaSample (double) frames decoded below and yields the canonical -// bank-format bytes (capture.cpp kRenderFormatWavFloat32; wav_codec.h FORMAT -// ASSUMPTION — the double→float narrowing is the intentional bank contract). +// buildFloat32Wav (wav_codec) takes the interleaved ReaSample (double) frames +// decoded below and yields the canonical bank-format bytes — the double->float +// narrowing is the intentional bank contract. -// Decodes ALL samples from `src` into interleaved double-precision frames. -// Returns empty on a zero-length or silent source (sampleRate < 1, channelCount == 0). -// Uses GetSamples in blocks; advances time_s monotonically. The caller has already -// queried channelCount and sampleRate from the same source; those values are passed in -// to avoid re-querying after GetSamples mutates decoder state. +// Returns empty on a zero-length or silent source. The caller has already queried +// channelCount/sampleRate from the same source (passed in to avoid re-querying +// after GetSamples mutates decoder state). std::vector decodePcmSource(PCM_source* src, int nch, double sampleRate, double lengthSeconds) { if (!src || nch <= 0 || sampleRate < 1.0 || lengthSeconds <= 0.0) return {}; @@ -132,7 +112,6 @@ std::vector decodePcmSource(PCM_source* src, int nch, double sampleRa std::vector out; out.reserve(totalFrames * static_cast(nch)); - // Pull samples in blocks of ~4096 frames; loop until source is exhausted. constexpr int kBlockFrames = 4096; std::vector block(static_cast(kBlockFrames * nch)); @@ -156,41 +135,24 @@ std::vector decodePcmSource(PCM_source* src, int nch, double sampleRa return out; } -// The result of an import-into-bank: the sample id to assign (the existing id on a -// hash-dedup collapse, the new id otherwise) and whether anything was added to the index -// (so the caller opens an undo point only for a real mutation). struct ImportResult { std::string sampleId; // "" on failure (nothing to assign) bool added = false; // true iff a NEW index entry was created (not a collapse) std::string message; // human-readable outcome for the console }; -// Imports one OS-native source file into the ACTIVE bank: convert-if-needed to 32-bit- -// float WAV, write to the project-relative bank folder, index-add, hash-dedup applied. +// Imports one OS-native source file into the ACTIVE bank: convert-if-needed to +// 32-bit-float WAV (the bank contract — a verbatim copy of anything else would be +// unplayable), write to the project-relative bank folder, index-add, hash-dedup. // -// BANK CONTRACT: the instrument (wav_codec parse) expects every bank file to be a canonical -// 32-bit-float WAV (WAVE_FORMAT_IEEE_FLOAT, 32 bits). A verbatim copy of a non-WAV (or -// an integer-PCM or double-float WAV) would be unplayable. This function therefore: -// 1. Checks whether the source IS already a valid 32f WAV (parseWavLayout fast path). -// 2. If yes: copies it verbatim — one I/O, content unchanged. -// 3. If no: decodes via PCM_source::GetSamples and writes a fresh 32f WAV, preserving -// the source's channel count and sample rate. +// DEDUP ORDERING: the content hash is taken from the CONVERTED bytes AFTER building +// the buffer but BEFORE writing to disk, so a re-import of the same source (or of a +// WAV matching a captured file's content) collapses without a redundant disk write. +// Hashing the raw source bytes instead would miss this for non-WAV sources, since +// their bytes differ from the converted WAV bytes. // -// DEDUP ORDERING: the content hash is taken from the CONVERTED (bank-format) bytes AFTER -// building the file buffer but BEFORE writing to disk. This means: -// * Re-importing the same source file yields the same converted bytes → same hash → -// dedup fires → no redundant disk write (matching the DEDUP-BEFORE-DISK design). -// * An imported WAV whose audio-content hash matches a captured WAV also deduplicates -// correctly (hashWavContent is chunk-aware for both). -// * The pre-conversion hash shortcut (hash the raw source bytes) is not used: a non-WAV -// source's bytes would produce a different hash from the converted WAV bytes, so two -// imports of the same mp3 would NOT dedup — which is wrong. Hashing post-conversion -// is correct. -// -// NON-DESTRUCTIVE: the source file is never modified or moved — only read. -// Records the written file in the owned-file manifest (Phase B B-cap) so Phase R prune can -// attribute it. Does NOT persist or open an undo point — the caller batches that (a -// multi-file drop is one undo point, one persist). +// NON-DESTRUCTIVE: the source file is only read. Does NOT persist or open an undo +// point — the caller batches that (a multi-file drop is one undo point, one persist). ImportResult importFileIntoActiveBank(const std::string& absoluteSourcePath) { ImportResult out; @@ -212,17 +174,14 @@ ImportResult importFileIntoActiveBank(const std::string& absoluteSourcePath) { return out; } - // Read source bytes; needed to check whether it is already a 32f WAV. const std::vector srcBytes = readFileBytes(absoluteSourcePath); if (srcBytes.empty()) { out.message = "file is empty or unreadable: " + absoluteSourcePath; return out; } - // Probe the source's audio geometry via PCM_source. Needed for conversion AND for - // populating the Sample's metadata. A file REAPER cannot open leaves geometry at - // zero — the sample still imports if the WAV-fast-path succeeds; the geometry - // is simply unknown, the honest default. + // A file REAPER cannot open leaves geometry at zero — the sample still imports + // if the WAV-fast-path succeeds; the geometry is simply unknown, the honest default. int channelCount = 0; int sampleRate = 0; double lengthSeconds = 0.0; @@ -235,22 +194,16 @@ ImportResult importFileIntoActiveBank(const std::string& absoluteSourcePath) { if (isQN) lengthSeconds = 0.0; // QN-length source has no seconds length to store } - // Determine whether a verbatim copy suffices (fast path) or a conversion is needed. - // parseWavLayout validates that the source is a canonical 32-bit-float RIFF/WAVE; any - // other format (mp3, aiff, integer PCM, 16-bit WAV, etc.) takes the decode+rewrite path. + // parseWavLayout validates a canonical 32-bit-float RIFF/WAVE; any other format + // (mp3, aiff, integer PCM, 16-bit WAV, etc.) takes the decode+rewrite path. const WavLayout layout = parseWavLayout(srcBytes); const bool isFloat32Wav = layout.valid; - // Build the bank-format bytes in memory (the "converted" bytes), which we hash for dedup - // BEFORE writing to disk so a re-import of the same source skips the disk write. std::vector bankBytes; if (isFloat32Wav) { - // Fast path: already canonical — bank bytes ARE the source bytes. bankBytes = srcBytes; if (srcHandle) PCM_Source_Destroy(srcHandle); } else { - // Conversion path: decode all samples then write a fresh 32f WAV. - // PCM_source is opened on the source path (not a copy); we already have srcHandle. std::vector decoded; if (srcHandle && channelCount > 0 && sampleRate > 0 && lengthSeconds > 0.0) { decoded = decodePcmSource(srcHandle, channelCount, @@ -259,10 +212,8 @@ ImportResult importFileIntoActiveBank(const std::string& absoluteSourcePath) { if (srcHandle) PCM_Source_Destroy(srcHandle); if (decoded.empty()) { - // No decodable audio. The source is on disk (valid path, REAPER could open it) - // but yielded no samples — e.g. a MIDI file, a zero-length audio file, or a - // format REAPER does not support. Fail loudly: we must not write a silent WAV - // and pretend the import succeeded. + // e.g. a MIDI file, zero-length audio, or an unsupported format. Fail + // loudly rather than write a silent WAV and pretend the import succeeded. out.message = "could not decode audio samples from: " + fs::path(absoluteSourcePath).filename().string() + " (unsupported format or no audio data)"; @@ -275,20 +226,17 @@ ImportResult importFileIntoActiveBank(const std::string& absoluteSourcePath) { static_cast(sampleRate), frameCount, decoded); } - // srcHandle is destroyed above in both branches. - // Hash the converted (bank-format) bytes for dedup. WAV-aware hash (hashWavContent) - // so a re-import of the same source deduplicates against a previously-captured or - // previously-imported sample with identical audio content, even if non-audio RIFF - // chunks differ. Empty hash (unhashable) is treated as "not dedupable" (safe direction: - // copies + adds rather than silently collapsing onto an unrelated entry). + // WAV-aware hash so a re-import deduplicates against a previously-captured or + // previously-imported sample with identical audio content, even if non-audio + // RIFF chunks differ. Empty (unhashable) is "not dedupable" — copies + adds + // rather than silently collapsing onto an unrelated entry. const std::string contentHash = hashWavContent(bankBytes); BankBook& book = g_session->book(); - // Dedup-before-disk: if the active bank already holds this audio content, assign the - // existing sample's id and skip the disk write (no redundant on-disk duplicate). - // Empty hashes never match (findByHash treats "" as non-participating). + // Dedup-before-disk: skip the write entirely if the active bank already holds + // this content. if (!contentHash.empty()) { if (const Sample* existing = book.activeIndex().findByHash(contentHash)) { out.sampleId = existing->id; @@ -298,14 +246,12 @@ ImportResult importFileIntoActiveBank(const std::string& absoluteSourcePath) { } } - // Derive the destination path. The stem comes from the source file name; a timestamp - // uniqueTag avoids collision with a prior import of a same-named file. + // A timestamp uniqueTag avoids collision with a prior import of a same-named file. const std::string sourceStem = fs::path(absoluteSourcePath).stem().string(); const std::int64_t nowSec = static_cast(std::time(nullptr)); const std::string uniqueTag = std::to_string(nowSec); const BankPaths paths = deriveBankPaths(projectDir, sourceStem, uniqueTag); - // Ensure the bank folder exists, then write the (converted) bank bytes. fs::create_directories(paths.absoluteDir, ec); // idempotent; ec ignored (write reports) const std::string destPath = paths.absoluteDir + "/" + paths.fileName; if (!writeFileBytes(destPath, bankBytes)) { @@ -313,10 +259,8 @@ ImportResult importFileIntoActiveBank(const std::string& absoluteSourcePath) { return out; } - // Build the Sample. Import is NOT a capture — sourceMode/range/tail do not apply; we - // record what we know (path, hash, geometry, name) and leave capture-only fields at - // their defaults. rootNote/loop stay empty: an imported file is not a single played - // note, so we do not guess a root note. + // Import is NOT a capture — capture-only fields stay at defaults. rootNote/loop + // stay empty: an imported file is not a single played note, so we do not guess. Sample s; s.id = "imp-" + uniqueTag + "-" + paths.fileName; s.displayName = sourceStem.empty() ? std::string("import") : sourceStem; @@ -329,10 +273,8 @@ ImportResult importFileIntoActiveBank(const std::string& absoluteSourcePath) { s.createdTimestamp = nowSec; const AddResult r = book.activeIndex().add(s); - // Record the written file as owned regardless of the add outcome — the tool WROTE it, so - // Phase R prune must attribute it. (A Collapsed result here would mean another sample in - // the active bank matched the hash after we passed the pre-write dedup check — a narrow - // race window. Record + handle both honestly.) + // Record as owned regardless of outcome — the tool WROTE the file, so prune must + // attribute it even in the narrow Collapsed race below. g_session->owned().add(paths.relativePath); switch (r) { @@ -343,8 +285,7 @@ ImportResult importFileIntoActiveBank(const std::string& absoluteSourcePath) { paths.relativePath; break; case AddResult::Collapsed: { - // The hash matched an existing entry (a race against our pre-write dedup check, - // or an empty-hash edge). Assign the existing entry's id. + // A race against the pre-write dedup check (or an empty-hash edge). const Sample* existing = contentHash.empty() ? nullptr : book.activeIndex().findByHash(contentHash); out.sampleId = existing ? existing->id : std::string{}; @@ -354,40 +295,30 @@ ImportResult importFileIntoActiveBank(const std::string& absoluteSourcePath) { } case AddResult::RejectedAbsolutePath: case AddResult::RejectedEmptyId: - // deriveBankPaths always yields a relative path and a non-empty id above, so - // these are unreachable in practice — reported honestly rather than silently. + // Unreachable in practice (deriveBankPaths always yields a relative path + // and non-empty id) — reported honestly rather than silently. out.message = "index rejected the import (internal path/id error)"; break; } return out; } -// --- Media-Explorer import action -------------------------------------------- - -// Import the Media Explorer's current last-played/selected file into the active bank, then -// add a ReaSampler 9000 instrument to the FIRST SELECTED TRACK pre-loaded with that sound. -// No new track is created; no routing changes are made — "new sound, existing track." -// No assignment_request is written on this path. +// Imports the Media Explorer's last-played/selected file into the active bank, then +// adds a ReaSampler 9000 instrument to the FIRST SELECTED TRACK pre-loaded with that +// sound — no new track, no routing changes. No assignment_request write. // -// Single-file, pull-on-action: MediaExplorerGetLastPlayedFileInfo returns the ONE last-played -// file (the whole ME contract — no enumerate-selected API). The selection RANGE it reports is -// deliberately IGNORED here: an import brings the whole file into the bank (the range is a -// preview hint, and the fields are [0,1] fractions, not seconds — see the DAW-verify note); -// a user wanting a sub-range captures it via the arrange path instead. +// Single-file, pull-on-action (MediaExplorerGetLastPlayedFileInfo — no +// enumerate-selected API). The selection RANGE it reports is deliberately IGNORED: +// an import brings the whole file in ([0,1] fraction fields are a preview hint, not +// seconds); a sub-range user captures via the arrange path instead. // -// LOAD-BEARING (CLAUDE.md): this adds ONE FX instance to the user's existing selected track. -// It NEVER inserts a timeline item and NEVER creates a track. Persist ordering is critical — -// the fresh instance's setState -> reloadInstrument reads the bank from project ext-state, so -// the sample MUST be persisted (generation bumped when something new landed) BEFORE +// LOAD-BEARING: NEVER inserts a timeline item, NEVER creates a track. Persist +// ordering is critical — the fresh instance's setState -> reloadInstrument reads the +// bank from project ext-state, so the sample MUST be persisted BEFORE // loadInstrumentOntoTrack adds the FX, or the instance cannot resolve the sampleId. -// Undo-wrapped: persist + FX-add + inject = one Ctrl-Z. -// -// No selected track: the bank import still proceeds (sound is now in the bank), but no -// instrument is placed and a clear console message explains why. void doImportFromMediaExplorer() { - // filemode/sel/pitch/vol/rate/bpm/extrainfo are read but only the filename is used for - // the import. selstart/selend are [0,1] fractions (SDK header) — a preview hint, not a - // bank-relevant range; left unused. extrainfo is documented "currently unused". + // Only the filename is used; selstart/selend are [0,1] fractions (a preview + // hint, not a bank-relevant range), extrainfo is documented "currently unused". std::vector nameBuf(4096, '\0'); int filemode = 0; double selStart = 0.0, selEnd = 0.0; @@ -406,21 +337,17 @@ void doImportFromMediaExplorer() { const ImportResult r = importFileIntoActiveBank(path); if (r.sampleId.empty()) { - // Import refused (unsaved project / undecodable / write failure). Report and stop — - // no instrument is placed. ShowConsoleMsg(("ReaSampler ingest: Media Explorer import failed -- " + r.message + ".\n").c_str()); return; } - // Resolve the first selected track. GetSelectedTrack(nullptr, 0): proj=nullptr=active - // project, seltrackidx=0=first selected (ignores master). Returns null when nothing is - // selected — directive: existing track only, never alter the graph. + // GetSelectedTrack ignores the master; null means nothing selected — existing + // track only, never alter the graph. MediaTrack* target = GetSelectedTrack(nullptr, 0); if (!target) { - // Sound landed in the bank; no instrument placed because there is no selected track. - // The bank import is kept (sound is available in the bank browser) and generation is - // bumped so any open VST3 browser instances refresh to show the new sound. + // Bank import is kept (sound is in the bank browser); generation is bumped + // so any open VST3 browser instances refresh to show the new sound. if (r.added) { Undo_BeginBlock2(nullptr); g_session->bumpBankGeneration(); @@ -439,24 +366,15 @@ void doImportFromMediaExplorer() { return; } - // Build the pre-loaded instrument payload (a .vstpreset image) for the resolved sampleId. - // Valid for BOTH the fresh import and the dedup case (added == false but a real sampleId) - // — the user asked for a player, and a valid sampleId is sufficient to pre-select the sound. + // Valid for BOTH the fresh import and the dedup case (added == false but a real + // sampleId is sufficient to pre-select the sound). const std::vector preset = buildInstrumentDropPreset(r.sampleId); - // One undo point for the whole gesture. Persist happens INSIDE the block and BEFORE the - // FX add so the new instance's setState -> reloadInstrument sees the just-persisted sample. - // The generation is bumped only when something NEW landed (a dedup collapse mutated nothing, - // so it needs neither a bump nor a persist to resolve — the sample is already in ext-state). - // If saveToActiveProject() no-ops (unsaved project), close with an empty label + zero flag so - // REAPER discards the undo entry (the house pattern from persistBankOp). importFileIntoActiveBank - // already refuses on an unsaved project, so in practice the persist here succeeds. + // Persist happens INSIDE the block and BEFORE the FX add so the new instance's + // setState -> reloadInstrument sees the just-persisted sample. Undo_BeginBlock2(nullptr); - bool persisted = true; // true when nothing needed persisting (dedup) — governs the label path + bool persisted = true; // true when nothing needed persisting (dedup) if (r.added) { - // S9: a new sample landed in the active bank -> bump inside the block so the stamped - // generation is what the fresh instance (and any other live instances) resolve against, - // and undo rolls the generation back with the banks key. g_session->bumpBankGeneration(); persisted = g_session->saveToActiveProject(); } @@ -465,9 +383,8 @@ void doImportFromMediaExplorer() { Undo_EndBlock2(nullptr, "ReaSampler: import from Media Explorer into selected track", UNDO_STATE_MISCCFG); else - // Either the FX add/inject failed (loadInstrumentOntoTrack already rolled the FX back — - // no orphan) or the project was unsaved (persist no-op): discard the undo entry so no - // empty point is recorded. + // Either the FX add/inject failed (already rolled back, no orphan) or the + // project was unsaved (persist no-op): discard so no empty point is recorded. Undo_EndBlock2(nullptr, "", 0); bankPanelRefresh(); @@ -481,30 +398,26 @@ void doImportFromMediaExplorer() { } // namespace -// --- Assignment-request write ------------------------------------------------ - void ingestAssignActiveInstance(const std::string& bankId, const std::string& sampleId) { if (!g_session || sampleId.empty()) return; // nothing to assign AssignmentRequest req; req.bankId = bankId; req.sampleId = sampleId; - // Monotonic disambiguator: a wall-clock unix-epoch stamp so the reader tells a fresh - // assign (even re-assigning the SAME id) from a stale value. NOT the S9 bank-generation - // counter (a separate point) — this field is self-contained to the request. + // Monotonic wall-clock stamp so the reader tells a fresh assign (even + // re-assigning the SAME id) from a stale value — self-contained to the request, + // not the bank-generation counter. req.generation = static_cast(std::time(nullptr)); g_session->writeAssignmentRequest(encodeAssignmentRequest(req)); } -// --- Drop-onto-panel ingest -------------------------------------------------- - +// Bank-fill only; no assignment_request is written (the drop has no effect on what +// any live instance plays). Batch the persist + undo point: many imports are ONE +// undo entry. void ingestDroppedFiles(const std::vector& absolutePaths) { if (!g_session || absolutePaths.empty()) return; - // Import ALL dropped files into the active bank — bank-fill only. No assignment_request - // is written on this path; the drop has no effect on what any live instance plays. - // Batch the persist + undo point: many imports are ONE undo entry. int importedNew = 0; int importedTotal = 0; std::string lastFailure; @@ -519,14 +432,9 @@ void ingestDroppedFiles(const std::vector& absolutePaths) { if (r.added) ++importedNew; } - // One undo point for the whole drop, opened only if a NEW index entry was created (a - // drop that only re-hit existing content mutated nothing on the index). - // If saveToActiveProject() no-ops (unsaved project), we close with an empty label + zero - // flag so REAPER discards the undo entry (house pattern from persistBankOp). + // One undo point for the whole drop, opened only if a NEW index entry was created. if (importedNew > 0) { Undo_BeginBlock2(nullptr); - // S9: one coalesced generation bump for the whole drop (>=1 new sample landed) so - // open VST3 browser instances refresh to show the newly available sounds. g_session->bumpBankGeneration(); const bool persisted = g_session->saveToActiveProject(); if (persisted) @@ -539,7 +447,6 @@ void ingestDroppedFiles(const std::vector& absolutePaths) { (importedTotal == 1 ? " file" : " files") + " into the bank.\n"; ShowConsoleMsg(msg.c_str()); } else if (importedTotal > 0) { - // All dropped files were already in the bank (deduplicated); nothing changed. bankPanelRefresh(); ShowConsoleMsg("ReaSampler ingest: all dropped files already in the bank.\n"); } else { @@ -549,10 +456,8 @@ void ingestDroppedFiles(const std::vector& absolutePaths) { } } -// --- Action registration ------------------------------------------------------ - void ingestRegisterActions(reaper_plugin_info_t* rec, ReaSamplerSession* session) { - g_session = session; // shared with the capture / bank / Design-View families + g_session = session; g_idImportStr = channelCommandId(kIdImportMediaExplorer); g_cmdImportMediaExplorer = rec->Register("command_id", (void*)g_idImportStr.c_str()); @@ -571,8 +476,7 @@ bool ingestHandleCommand(int command) { } void ingestUnregisterActions(reaper_plugin_info_t* rec) { - // Mirror-unregister with '-'-prefixed strings; the '-command_id' re-presents the SAME - // interned channel-qualified id used at register (g_idImportStr). + // '-command_id' re-presents the SAME interned id used at register (g_idImportStr). rec->Register("-gaccel", (void*)&g_accelImportMediaExplorer); rec->Register("-command_id", (void*)g_idImportStr.c_str()); g_session = nullptr; diff --git a/src/ingest.h b/src/ingest.h index a6b2203..3c4fa4b 100644 --- a/src/ingest.h +++ b/src/ingest.h @@ -1,77 +1,50 @@ #pragma once -// ingest — the S8 "ingest through the bank" shell (EXTENSION side). -// -// Compiled into the reaper_reasampler MODULE. REAPER-facing (PCM_Source metadata reads, -// Media-Explorer query, ext-state assignment write, action registration), so it is -// DAW-verified, not unit-tested; the pure serialization it drives lives in -// assignment_request (tested in CTest). -// -// -- The one gesture (CONTEXT.md §Ingest through the bank) -------------------- +// ingest — the "ingest through the bank" shell (EXTENSION side). REAPER-facing +// (PCM_Source metadata reads, Media-Explorer query, ext-state assignment write, +// action registration), so DAW-verified, not unit-tested; the pure serialization it +// drives lives in assignment_request (CTest). // // Loading a sample into the sampler is ONE gesture: capture/import-into-bank AND -// auto-assign to the active sampler instance. The EXTENSION owns ingest (it has arrange -// access, Media-Explorer access, and the drop-target surface on its own panels); the -// instrument stays a READ-ONLY bank consumer. Three ingest surfaces: +// auto-assign to the active sampler instance. The EXTENSION owns ingest (arrange +// access, Media-Explorer access, drop-target surface); the instrument stays a +// READ-ONLY bank consumer. Three surfaces: (1) arrange capture -> bank -> assign, +// (2) Media-Explorer import -> bank -> assign (single-file, pull-on-action), (3) +// drop-onto-panel -> bank -> assign (multi-file: import all, assign the first). // -// 1. Arrange capture -> bank -> assign (a bindable action; reuses the capture path). -// 2. Media-Explorer import -> bank -> assign (a bindable action; single-file, pull-on- -// action via MediaExplorerGetLastPlayedFileInfo). -// 3. Drop-onto-panel -> bank -> assign (an OS file drop on the docked bank_panel HWND; -// multi-file: import all, assign the first). +// LOAD-BEARING: ingest NEVER inserts a timeline item — capture writes a file + index +// entry, import copies a file + adds an index entry, assignment is a bank-index + +// instance-selection act, not a placement. Any InsertMedia call here is a bug. // -// -- The load-bearing principle (restated) ----------------------------------- -// -// Ingest NEVER inserts a timeline item. Capture writes a file + an index entry; import -// copies a file + adds an index entry; assignment is a bank-index + instance-selection -// act, not a placement. Any path here that calls InsertMedia would be a bug. -// -// -- Import semantics --------------------------------------------------------- -// -// A Media-Explorer/drop import is a FILE COPY into the project-relative bank folder + -// an index add, mirroring how a capture lands (relative-paths-only, hash-dedup). If the -// active bank already holds the imported content (by content hash), the import collapses -// onto the existing sample and assigns THAT sample's id — no redundant on-disk copy. +// Import is a FILE COPY into the project-relative bank folder + an index add +// (relative-paths-only, hash-dedup). If the active bank already holds the content +// (by hash), the import collapses onto the existing sample instead of duplicating. #include #include -// Forward declarations keep this header REAPER-free at its own boundary (the .cpp pulls -// the SDK). reaper_plugin_info_t is REAPER's dispatch struct; ReaSamplerSession owns the -// book + persist bridge the ingest paths mutate. +// Forward declarations keep this header REAPER-free (the .cpp pulls the SDK). struct reaper_plugin_info_t; namespace reasampler { class ReaSamplerSession; -// Registers the S8 ingest action family (command_id/gaccel per the house contract), -// mirror of bankRegisterActions. `session` is the live session the ingest paths mutate -// (shared with the capture / bank / Design-View families). The single hookcommand in -// main.cpp routes fired ids here via ingestHandleCommand. +// `session` is shared with the capture / bank / Design-View families; the single +// hookcommand in main.cpp routes fired ids here via ingestHandleCommand. void ingestRegisterActions(reaper_plugin_info_t* rec, ReaSamplerSession* session); -// Routes a fired command id to its ingest action. Returns true iff it was one of ours -// (claim-only, per the hookcommand contract); false otherwise so the hook keeps looking. bool ingestHandleCommand(int command); -// Mirror-unregisters the ingest action family on unload (the '-'-prefixed strings). void ingestUnregisterActions(reaper_plugin_info_t* rec); -// Write the S8 assignment request for a just-ingested sample: "the active sampler -// instance should now play (bankId, sampleId)." Encodes the pure assignment_request value -// (with a fresh monotonic generation stamp) and routes it to ext state via the session. -// Called by EVERY ingest surface after the sample lands in the bank — the arrange -// capture+assign action (main.cpp, alongside the capture machinery it reuses), the ME -// import action, and the drop path. A no-op-safe write: if there is no saved/active -// project the request is silently dropped (nothing to signal into), matching the -// book/manifest quiet-persist idiom. `sampleId` empty -> no write (nothing to assign). +// "The active sampler instance should now play (bankId, sampleId)." Called by EVERY +// ingest surface after the sample lands in the bank. No-op-safe: an unsaved/no-active +// project silently drops the write; `sampleId` empty -> no write. void ingestAssignActiveInstance(const std::string& bankId, const std::string& sampleId); -// Ingest OS-dropped files onto a ReaSampler surface (S8 drop path). Called by the -// bank_panel's WM_DROPFILES handler with the dropped file paths (absolute, OS-native). -// Imports EVERY file into the active bank (copy + index add, hash-dedup) and assigns the -// FIRST successfully-imported sample to the active instance. A no-op on an empty list or -// an unsaved/no-active project (nothing to import into). Reports outcomes to the console. +// Called by the bank_panel's WM_DROPFILES handler. Imports EVERY file into the +// active bank (hash-dedup) and assigns the FIRST successfully-imported sample to the +// active instance. No-op on an empty list or an unsaved/no-active project. void ingestDroppedFiles(const std::vector& absolutePaths); } // namespace reasampler diff --git a/src/resource.h b/src/resource.h index 1064b9a..63615cb 100644 --- a/src/resource.h +++ b/src/resource.h @@ -1,10 +1,8 @@ #pragma once -// resource.h — dialog/control ids for ReaSampler's SWELL dialogs. -// -// Shared by resource.rc (Windows resource compiler) and, on macOS/Linux, by the -// SWELL resgen-generated source (see CLAUDE.md §SWELL dialog resources). Keep the -// numeric ids stable and unique across the extension. +// resource.h — dialog/control ids for ReaSampler's SWELL dialogs. Shared by +// resource.rc and, on macOS/Linux, the SWELL resgen-generated source. Keep ids +// stable and unique across the extension. -// The docked bank panel (M5). A bare owner-drawn child dialog: it carries no -// controls — the panel shell (shell/panel/panel_render.cpp) paints the whole client area with LICE. +// A bare owner-drawn child dialog with no controls — panel_render.cpp paints the +// whole client area with LICE. #define IDD_BANK_PANEL 1000 diff --git a/src/shell/actions/action_registry.cpp b/src/shell/actions/action_registry.cpp index 00ece1a..06eff0f 100644 --- a/src/shell/actions/action_registry.cpp +++ b/src/shell/actions/action_registry.cpp @@ -1,6 +1,5 @@ -// action_registry.cpp — shared registration plumbing (Q-W4) + the registration -// table (Q-W6). See action_registry.h. Needs no REAPER API pointers: rec->Register -// is a member call on the dispatch struct REAPER hands the entry point. +// action_registry.cpp — see action_registry.h. Needs no REAPER API pointers: +// rec->Register is a member call on the dispatch struct REAPER hands the entry point. #include "shell/actions/action_registry.h" @@ -17,16 +16,12 @@ namespace { using version::channelActionName; using version::channelCommandId; -// Durable store of composed, channel-qualified strings (ids + labels). A std::deque -// never invalidates references on push_back, so a c_str() handed to REAPER (a -// command_id at register, a gaccel desc for its lifetime) stays valid until process -// exit. Memoized by suffix so register and the mirror-unregister get the SAME id -// pointer for a given action. +// std::deque never invalidates references on push_back, so a c_str() handed to +// REAPER stays valid until process exit. Memoized by suffix so register and +// mirror-unregister get the SAME id pointer. std::deque g_strStore; -// One registered table row: the row data plus the registry-owned registration -// artifacts (interned id, minted cmd, gaccel storage REAPER holds a pointer to). -// A std::deque so element addresses never move after push_back — REAPER keeps each +// std::deque so element addresses never move after push_back — REAPER keeps each // &accel until the mirror-unregister. struct TableEntry { ActionTableRow row; diff --git a/src/shell/actions/action_registry.h b/src/shell/actions/action_registry.h index 9f45fbe..e92c7d4 100644 --- a/src/shell/actions/action_registry.h +++ b/src/shell/actions/action_registry.h @@ -1,28 +1,10 @@ #pragma once -// action_registry — shared registration plumbing + the Q-W6 registration TABLE. -// -// Two layers, one TU: -// -// * The Q-W4 plumbing (channelIdFor / registerAction): the durable interned-string -// store the action families register through, so a composed command id keeps ONE -// stable pointer from register to the mirror-unregister, and the -// register-a-command_id-then-gaccel sequence has one implementation. The -// design_view / bank / ingest families still register row-by-row through this. -// -// * The Q-W6 registration TABLE (ActionTableRow + registerActionTable / -// actionTableHandleCommand / actionTableCommandId / unregisterActionTable): the -// data-driven home of main.cpp's own action family (capture scopes, panel toggle, -// insert, batch, realtime, recapture, version). One row = one action (FOREVER- -// STABLE id suffix, display phrase, flat function-pointer handler); registration -// iterates the rows, hookcommand dispatch walks the same rows, and unload -// mirror-unregisters from them — adding an action touches the table only (OCP). -// Handlers are plain function pointers (a static dispatch walk, no std::function, -// no virtual — the §3 performance guardrail); gaccel + interned-id storage is -// owned here for the module lifetime, so REAPER's held pointers stay valid and -// the '-command_id' unregister re-presents the IDENTICAL pointer registered. -// -// Includes reaper_plugin.h (gaccel_register_t / reaper_plugin_info_t full defs); -// only the action-family TUs and main.cpp include this header. +// action_registry — shared REAPER registration plumbing, plus a data-driven action +// table (ActionTableRow) so adding an action means adding one row, not touching +// register/dispatch/unregister separately (OCP). Interned command-id/label strings +// persist for the module lifetime: REAPER holds those pointers, and an unregister +// must re-present the SAME one. Handlers are flat function pointers, never +// std::function/virtual (hot-path-adjacent dispatch discipline). #include @@ -30,27 +12,19 @@ namespace reasampler { -// Returns the channel-qualified command id for `suffix`, interning it once for the -// process lifetime. Called by BOTH registerAction and each family's unregister path, -// so a '-command_id' presents the IDENTICAL string pointer registered earlier. +// Interns the channel-qualified command id for `suffix` once per process, so a +// '-command_id' unregister presents the IDENTICAL pointer registered earlier. const char* channelIdFor(const char* suffix); -// Mints a command id from a channel-qualified SUFFIX and registers its gaccel -// (Actions-list entry with a channel-qualified label PHRASE). Returns the command id -// (0 on failure). Both the composed id and label are interned durably — REAPER holds -// the desc pointer, and the id must survive to the mirror-unregister. The gaccel -// storage itself is caller-owned (file-scope in the family TU). +// Mints a command id from `suffix`, registers its gaccel with label `phrase`. +// Returns the id (0 on failure); gaccel storage is caller-owned. int registerAction(reaper_plugin_info_t* rec, const char* suffix, gaccel_register_t& accel, const char* phrase); -// --- The registration table (Q-W6) ------------------------------------------- +// --- The registration table --------------------------------------------------- -// One bindable action. `suffix` and `phrase` are the channel-AGNOSTIC pieces (the -// registry composes the full id/label via channelCommandId / channelActionName); -// both must have static storage duration (string literals, or a pure static table -// like captureActionTable()). `run` fires when the minted command does; `arg` is an -// opaque per-row value passed through to it (e.g. a captureActionTable row index, or -// a bool-like flag), so sibling actions can share one handler without captures. +// `suffix`/`phrase` are channel-agnostic and must have static storage duration. +// `arg` is an opaque per-row value so sibling actions can share one handler. struct ActionTableRow { const char* suffix; // FOREVER-STABLE command-id suffix — never change shipped const char* phrase; // Actions-list display phrase (after the channel prefix) @@ -58,26 +32,19 @@ struct ActionTableRow { int arg = 0; // opaque per-row handler argument }; -// Registers every row (command_id -> gaccel, via the same interning plumbing as -// registerAction) in table order. Rows are COPIED into registry-owned storage whose -// element addresses never move (REAPER holds each gaccel pointer until unload). -// Call once at load; a failed command_id mint (cmd 0) leaves that row inert but -// still mirror-unregistered on unload (harmless, matches the pre-table behavior). +// Rows are copied into registry-owned storage whose addresses never move (REAPER +// holds each gaccel pointer until unload). void registerActionTable(reaper_plugin_info_t* rec, const ActionTableRow* rows, std::size_t count); -// Dispatches one fired command: fires the matching row's handler and returns true; -// false when the command belongs to no table row (caller's hookcommand keeps -// looking, per the claim-only contract). A flat walk over the registered rows. bool actionTableHandleCommand(int command); -// The minted command id for `suffix` (0 when unregistered / mint failed). For the -// callers that need a raw command id outside dispatch — e.g. the toggleaction -// checked-state hook resolving TOGGLE_BANK_PANEL once at load. +// 0 when unregistered / mint failed — for callers needing a raw id outside dispatch +// (e.g. the toggleaction checked-state hook). int actionTableCommandId(const char* suffix); -// Mirror-unregisters every table row (reverse table order): '-gaccel' with the same -// held storage, '-command_id' with the SAME interned pointer used at register. +// Mirror-unregisters every table row (reverse order): '-gaccel' with the held +// storage, '-command_id' with the SAME interned pointer used at register. void unregisterActionTable(reaper_plugin_info_t* rec); } // namespace reasampler diff --git a/src/shell/actions/bank_actions.cpp b/src/shell/actions/bank_actions.cpp index 32e854d..f5c3a63 100644 --- a/src/shell/actions/bank_actions.cpp +++ b/src/shell/actions/bank_actions.cpp @@ -1,23 +1,17 @@ -// bank_actions.cpp — the multi-bank bindable action family (Phase B3; Q-W4 split of -// actions.cpp). See bank_actions.h. +// bank_actions.cpp — see bank_actions.h. // -// Q-W4 dedupe / Q-W6 seam: each mutating handler is a THIN UX SKIN — text prompts -// (promptBankName), name resolution, and console feedback — over the promptless -// bankOp* inner verbs homed in shell/bank_ops (model op + persistBankOp, one bank op -// = one Ctrl-Z), driven against this family's registered session. The book's rules -// (pool privileges, collapse-by-hash, active-fallback-to-pool) all live in -// bank_book; these handlers only drive the verbs and react to the boolean. +// Each mutating handler is a thin UX skin — text prompts, name resolution, console +// feedback — over the promptless bankOp* verbs in shell/bank_ops (model op + +// persistBankOp, one bank op = one Ctrl-Z). Pool privileges / collapse-by-hash / +// active-fallback-to-pool live in bank_book; handlers only drive the verbs. // -// REFERENCE-INVALIDATION GUARDRAIL (B2 review): book().activeIndex() / bank()->index -// return a reference INTO the book's internal vector, which a create/delete can -// reallocate. No handler here caches a BankModel& (or a Bank*) across a structural -// mutation — each resolves ids to strings up front and re-resolves after any -// create/delete. Move/copy pass ids (not references) straight to the verbs. +// REFERENCE-INVALIDATION GUARDRAIL: book().activeIndex() / bank()->index return a +// reference INTO the book's internal vector, which a create/delete can reallocate. +// No handler caches a BankModel&/Bank* across a structural mutation — ids are +// resolved to strings up front and re-resolved after any create/delete. // -// Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.h -// WITHOUT REAPERAPI_IMPLEMENT — main.cpp owns the API pointers (CLAUDE.md §contract). -// The action ids are minted from FOREVER-STABLE strings; user keybindings key off -// them, so they must never change after ship. +// main.cpp owns the API pointers; this TU gets them extern. Action ids are minted +// from FOREVER-STABLE strings — never change one after ship. #include "shell/actions/bank_actions.h" @@ -42,11 +36,9 @@ namespace reasampler { namespace { -// FOREVER-STABLE multi-bank action-id SUFFIXES (Phase V, V4). The channel family prefix is -// prepended at register via channelCommandId (as with the Design View family) — stable -// rebuilds the shipped id, beta the isolated one. NEVER change a shipped suffix. -// Each suffix + the stable prefix must byte-match the pre-V4 shipped literal exactly -// (e.g. "BANK_REMOVE_SELECTED" -> "CEREBELLUM_REASAMPLER_BANK_REMOVE_SELECTED"). +// FOREVER-STABLE action-id SUFFIXES: the channel prefix is prepended at register +// (channelCommandId); NEVER change a shipped suffix — user keybindings key off the +// composed id. constexpr const char* kIdBankCreate = "BANK_CREATE"; constexpr const char* kIdBankRename = "BANK_RENAME"; constexpr const char* kIdBankDelete = "BANK_DELETE"; @@ -58,14 +50,10 @@ constexpr const char* kIdBankCopySel = "BANK_COPY_SELECTED"; constexpr const char* kIdBankRemoveSel = "BANK_REMOVE_SELECTED"; constexpr const char* kIdBankPoolFull = "BANK_POOL_FULLHEIGHT"; constexpr const char* kIdBankBanksFull = "BANK_BANKS_FULLHEIGHT"; -// Phase R (Reclaim), R2: the FOREVER-STABLE "Prune bank folder" id. Registered NOW so -// in-DAW dry-run verification is possible; R2 behaviour is REPORT-ONLY (no deletion), -// and R3 extends the confirm-and-delete step behind this SAME id — never a throwaway id. constexpr const char* kIdBankPruneFolder = "BANK_PRUNE_FOLDER"; -// The live session the actions read (name resolution, member counts, prune) and -// pass to the bankOp* verbs by reference (bankHandleCommand guards it non-null -// before any handler runs). Set once by bankRegisterActions; not owned here. +// Not owned here; set once by bankRegisterActions. bankHandleCommand guards it +// non-null before any handler runs. ReaSamplerSession* g_session = nullptr; int g_cmdBankCreate = 0; @@ -96,25 +84,17 @@ gaccel_register_t g_accelBankPoolFull{}; gaccel_register_t g_accelBankBanksFull{}; gaccel_register_t g_accelBankPruneFolder{}; -// Resolves a user-typed bank reference (a display name) to a bank id, scanning the -// book's banks in ordinal order. Exact match on displayName; "Pool" resolves the pool. -// Returns "" when no bank carries that name. Kept in the action layer (not the model) -// — it is UI name-resolution, not a model rule. First-match is unambiguous BY -// CONSTRUCTION: the model enforces unique display names (trimmed + case-insensitive), -// so at most one bank can carry a given name — no duplicate can shadow another here. +// Resolves a user-typed display name to a bank id ("" if none matches). UI name +// resolution, not a model rule — kept here rather than the model. Unambiguous by +// construction: the model enforces unique display names. std::string bankIdByDisplayName(const std::string& name) { for (const Bank& b : g_session->book().banks()) if (b.displayName == name) return b.id; return {}; } -// -- Action bodies (thin UX skins over the bankOp* verbs) ------------------- - -// Create a named bank: prompt for a display name; the verb mints a stable GUID id, -// creates it in the model, persists. The new bank is NOT auto-activated (create and -// activate are distinct acts — mirrors capture/placement separation). The model -// rejects a duplicate display name (trimmed + case-insensitive, incl. "Pool"); the -// create then fails and the user is told the name is taken. +// The new bank is NOT auto-activated (create and activate are distinct acts, +// mirroring capture/placement separation). void doBankCreate() { std::string name; if (!promptBankName("ReaSampler: create bank", "Bank name:", "", name)) return; @@ -126,9 +106,8 @@ void doBankCreate() { } } -// Rename a bank: prompt for which bank (by current display name) and the new name. -// The pool is un-renamable (the model rejects it). Two prompts keep the bindable form -// self-contained; the panel renames in place on a tab. +// Two prompts (which bank, then the new name) keep this bindable form +// self-contained; the panel renames in place on a tab instead. void doBankRename() { std::string which; if (!promptBankName("ReaSampler: rename bank", "Bank to rename (current name):", "", @@ -142,18 +121,13 @@ void doBankRename() { std::string newName; if (!promptBankName("ReaSampler: rename bank", "New name:", which, newName)) return; if (!bankOpRename(*g_session, id, newName)) { - // The verb rejects the pool (un-renamable) or a name already used by another - // bank (unique display names, trimmed + case-insensitive). ShowConsoleMsg("ReaSampler: cannot rename that bank (the pool is un-renamable, " "or another bank already uses that name).\n"); } } -// Delete a named bank. Bindable safe-form of the confirm-on-non-empty guardrail: -// prompt for the bank; if it holds members, a YESNO ShowMessageBox names evacuate as -// the alternative before dropping them (a plain delete orphans those members' files -// until prune — CONTEXT.md §delete). An empty bank deletes with no prompt. The richer -// panel confirm (naming evacuate inline, with a one-click evacuate) lives in the panel. +// If the bank holds members, confirm first (a plain delete orphans those members' +// files until prune); an empty bank deletes with no prompt. void doBankDelete() { std::string which; if (!promptBankName("ReaSampler: delete bank", "Bank to delete:", "", which)) return; @@ -162,15 +136,13 @@ void doBankDelete() { ShowConsoleMsg(("ReaSampler: no bank named \"" + which + "\".\n").c_str()); return; } - // Pool early-out: the pool is un-deletable (the model rejects it). Catch it here, - // BEFORE the non-empty confirm, so typing "Pool" never shows a misleading - // "delete anyway?" prompt for an operation the model will refuse regardless. + // Catch the pool BEFORE the non-empty confirm, so typing "Pool" never shows a + // misleading "delete anyway?" for an operation the model will refuse regardless. if (id == kPoolBankId) { ShowConsoleMsg("ReaSampler: the pool cannot be deleted.\n"); return; } - // Read member count BEFORE deleting (the Bank* is invalidated by the delete; we do - // not cache it — resolve size to an int up front). + // Read member count before deleting — the Bank* is invalidated by the delete. const Bank* b = g_session->book().bank(id); if (!b) return; // race-safe: id resolved above but re-check const std::size_t members = b->index.size(); @@ -184,16 +156,14 @@ void doBankDelete() { const int r = ShowMessageBox(msg.c_str(), "ReaSampler: delete non-empty bank", 4); if (r != 6) return; // 6 == YES; anything else cancels (SDK ~6544) } - // S9: bump only when the deleted bank held samples — dropping them changes what a live - // instance referencing one could play. Deleting an EMPTY bank is purely organizational. + // Bump only when the deleted bank held samples — dropping them changes what a + // live instance referencing one could play. if (!bankOpDelete(*g_session, id, /*bumpGeneration=*/members > 0)) { ShowConsoleMsg("ReaSampler: cannot delete that bank (the pool is un-deletable).\n"); } } -// Evacuate a named bank: move every member back to the pool (index-only, collapse by -// hash), leaving the bank empty. The pool is un-evacuable (the verb rejects it). The -// intended "keep the samples" companion to delete. +// The "keep the samples" companion to delete: moves every member back to the pool. void doBankEvacuate() { std::string which; if (!promptBankName("ReaSampler: evacuate bank", "Bank to evacuate to the pool:", "", @@ -210,10 +180,8 @@ void doBankEvacuate() { } } -// Cycle the active bank forward in ordinal order (pool -> named -> ... -> pool), -// via the pure nextBankId helper. Activating a bank changes the CAPTURE TARGET (the -// next capture lands in the newly-active bank — B2's book().activeIndex() seam) and -// never touches the timeline. The verb persists so the active id travels with the .rpp. +// Cycles the active bank (pool -> named -> ... -> pool). Activating changes the +// CAPTURE TARGET only — never touches the timeline. void doBankActivateNext() { std::vector ids; ids.reserve(g_session->book().size()); @@ -223,20 +191,13 @@ void doBankActivateNext() { bankOpActivate(*g_session, target); } -// Activate the pool directly (the common "back to the default target" jump). Bindable -// direct-by-id form; a general activate-bank-by-name/menu is a panel affordance. void doBankActivatePool() { bankOpActivate(*g_session, kPoolBankId); } -// Move or copy the panel's selected samples into a named destination bank (prompted -// by display name). The SOURCE is the bank the selection lives in — the focused -// region's displayed bank (bankPanelSelectedSourceBankId), which under B4's vertical -// split is NOT necessarily the active/capture-target bank (active ≠ shown). Both are -// index-only (files never relocate); the verb owns the verb-aware no-op guardrail and -// destination collapse-by-hash. The panel's "move to bank" menu drives the same verb -// with a menu-chosen destination — this bindable form is the same operation with a -// text-prompt destination. +// SOURCE is the bank the selection lives in (bankPanelSelectedSourceBankId), which is +// NOT necessarily the active/capture-target bank — the vertical split can show a +// different bank than the one active for capture. void doBankTransferSelected(bool copy) { const std::vector selected = bankPanelSelectedSampleIds(); if (selected.empty()) { @@ -253,7 +214,6 @@ void doBankTransferSelected(bool copy) { ShowConsoleMsg(("ReaSampler: no bank named \"" + destName + "\".\n").c_str()); return; } - // Source = the bank the selection lives in (the focused region's displayed bank). const std::string srcId = bankPanelSelectedSourceBankId(); if (srcId == destId) { ShowConsoleMsg("ReaSampler: source and destination are the same bank.\n"); @@ -262,10 +222,8 @@ void doBankTransferSelected(bool copy) { bankOpTransfer(*g_session, selected, srcId, destId, copy); } -// Remove the panel's selected samples from the SOURCE bank (the focused region's -// displayed bank — same source as move/copy). Index-only and non-destructive to the -// file (orphaned until Phase R prune); silent, with the batched undo as recovery — -// see bankOpRemove for the full contract. +// Index-only and non-destructive to the file (orphaned until prune); silent, with +// the batched undo as recovery. void doBankRemoveSelected() { const std::vector selected = bankPanelSelectedSampleIds(); if (selected.empty()) { @@ -307,8 +265,6 @@ void bankRegisterActions(reaper_plugin_info_t* rec, ReaSamplerSession* session) "toggle pool full-height"); g_cmdBankBanksFull = registerAction(rec, kIdBankBanksFull, g_accelBankBanksFull, "toggle banks full-height"); - // Phase R, R2: the "Prune bank folder" action (report-only in this wave; R3 extends - // the confirm-and-delete step behind this SAME forever-stable id). g_cmdBankPruneFolder = registerAction(rec, kIdBankPruneFolder, g_accelBankPruneFolder, "prune bank folder"); } @@ -335,8 +291,8 @@ bool bankHandleCommand(int command) { int bankPruneCommandId() { return g_cmdBankPruneFolder; } void bankUnregisterActions(reaper_plugin_info_t* rec) { - // Mirror-unregister with '-'-prefixed strings, reverse of registration order. Each - // '-command_id' re-presents the same interned channel-qualified id (channelIdFor). + // Reverse of registration order; each '-command_id' re-presents the same + // interned id (channelIdFor). rec->Register("-gaccel", (void*)&g_accelBankPruneFolder); rec->Register("-command_id", (void*)channelIdFor(kIdBankPruneFolder)); rec->Register("-gaccel", (void*)&g_accelBankBanksFull); diff --git a/src/shell/actions/bank_actions.h b/src/shell/actions/bank_actions.h index f8221e3..ac86344 100644 --- a/src/shell/actions/bank_actions.h +++ b/src/shell/actions/bank_actions.h @@ -1,45 +1,29 @@ #pragma once -// bank_actions — the multi-bank bindable action family (Phase B3; Q-W4 split of -// actions.h). The bindable action set that drives the multi-bank workflow: create / -// rename / delete / evacuate a bank, activate a bank (direct pool + cycle), move / -// copy / remove the panel's selected samples, the two vertical-split full-height -// toggles, and the Phase R prune action's registration + dispatch (its guarded body -// lives in prune_action). Q-W4 dedupe: every mutating handler here is a THIN UX skin -// (text prompts + console messages) over the promptless bankOp* verbs homed in -// panel_bank_ops — one implementation home for each mutation, two UX skins (this -// family prompts for which bank; the panel acts on a clicked tab). +// bank_actions — the multi-bank bindable action family: create/rename/delete/ +// evacuate a bank, activate (direct pool + cycle), move/copy/remove the panel's +// selected samples, the two vertical-split full-height toggles, and the prune +// action's registration + dispatch (guarded body in prune_action). Every mutating +// handler is a thin UX skin over the promptless bankOp* verbs in bank_ops (this +// family prompts for a bank name; the panel acts on a clicked tab). // -// Same registration/routing/unload contract as the Design View family -// (design_view_actions); both share main.cpp's single hookcommand, and each family's -// Handle claims only its own ids. This header is SDK-free. +// Same registration/routing/unload contract as design_view_actions. SDK-free header. -// Forward-declared at GLOBAL scope (matches reaper_plugin.h's typedef) so this -// header stays SDK-free; the .cpp includes the real definition. -struct reaper_plugin_info_t; +struct reaper_plugin_info_t; // global scope, matches reaper_plugin.h's typedef namespace reasampler { class ReaSamplerSession; -// Registers the multi-bank family against `rec`. `session` is the live session (must -// outlive registration). Call exactly once at load — pass the SAME session pointer -// the Design View family receives. +// `session` must outlive registration — pass the SAME pointer the Design View +// family receives. void bankRegisterActions(reaper_plugin_info_t* rec, ReaSamplerSession* session); -// Services one fired command for the multi-bank family. True iff it was one of this -// family's ids (and handled); false otherwise so the caller's hookcommand keeps -// looking. Safe for any command. bool bankHandleCommand(int command); -// Mirror-unregisters the multi-bank family with '-'-prefixed strings. Call once on -// rec==nullptr (before the session is torn down). void bankUnregisterActions(reaper_plugin_info_t* rec); -// The registered command id for the "Prune bank folder" action (Phase R, R3), or 0 -// before registration. The bank_panel prune button fires the action THROUGH this id -// via Main_OnCommand (fork R-E: the button dispatches the command, it does not call -// the session directly) so the panel affordance and the bindable action share one -// guarded code path. +// The bank_panel prune button fires THROUGH this id (Main_OnCommand) rather than +// calling the session directly, so button and bindable action share one guarded path. int bankPruneCommandId(); } // namespace reasampler diff --git a/src/shell/actions/design_view_actions.cpp b/src/shell/actions/design_view_actions.cpp index a05dfb9..5e0d4ca 100644 --- a/src/shell/actions/design_view_actions.cpp +++ b/src/shell/actions/design_view_actions.cpp @@ -1,24 +1,16 @@ -// design_view_actions.cpp — the Design View action family (Phase D4; Q-W4 split of -// actions.cpp). See design_view_actions.h. +// design_view_actions.cpp — see design_view_actions.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). The action ids are minted from FOREVER-STABLE strings (the -// same CEREBELLUM_REASAMPLER_ family prefix main.cpp uses); user keybindings key off -// them, so they must never change after ship. +// main.cpp owns the API pointers; this TU gets them extern. Action ids are minted +// from FOREVER-STABLE strings — never change one after ship. // -// Each action: -// 1. mutates the session's ViewModeModel (membership tag/untag/show-both, or the -// active mode via toggle/activate) — the pure D1 state, -// 2. reapplies the active mode through the D2 view shell (applyMode) so the change -// takes visible effect immediately (tagging a track into Design while in Arrange -// parks it right away; a mode change re-partitions and re-parks in one step). +// Each action mutates the session's ViewModeModel (membership tag/untag/show-both, or +// the active mode via toggle/activate), then reapplies the active mode through the +// view shell (applyMode) so the change takes visible effect immediately. // // Selection-driven mutations iterate the CURRENT REAPER track selection -// (CountSelectedTracks/GetSelectedTrack — both ignore the master, which is correct: -// the master is never tagged) and resolve each track to its canonical GUID key via -// the shared guidString helper, so the keys match exactly what the D2 shell / view -// tree key on (the cross-module key contract). +// (CountSelectedTracks/GetSelectedTrack ignore the master, which is correct — the +// master is never tagged) and resolve each track to its canonical GUID key so the +// keys match what the view shell / view tree key on. #include "shell/actions/design_view_actions.h" @@ -55,11 +47,8 @@ using view::isOnManualLane; namespace { -// FOREVER-STABLE action-id SUFFIXES (Phase V, V4). The channel family prefix is prepended -// at register time via channelCommandId (app_version), so stable rebuilds the exact shipped -// id ("CEREBELLUM_REASAMPLER_VIEW_TOGGLE_MODE") and beta yields the isolated forever-family -// id ("CEREBELLUM_REASAMPLER_BETA_VIEW_TOGGLE_MODE"). Each composed id is minted into a -// persistent command id user keybindings key off — NEVER change a shipped suffix after ship. +// FOREVER-STABLE action-id SUFFIXES: the channel prefix is prepended at register +// (channelCommandId) — NEVER change a shipped suffix. constexpr const char* kIdToggleMode = "VIEW_TOGGLE_MODE"; constexpr const char* kIdActivateArrange = "VIEW_ACTIVATE_ARRANGE"; constexpr const char* kIdActivateDesign = "VIEW_ACTIVATE_DESIGN"; @@ -67,17 +56,14 @@ constexpr const char* kIdTagDesign = "VIEW_TAG_DESIGN"; constexpr const char* kIdTagArrange = "VIEW_TAG_ARRANGE"; constexpr const char* kIdUntag = "VIEW_UNTAG"; constexpr const char* kIdShowBoth = "VIEW_SHOW_BOTH"; -// D2 Wave 3-B item-level mode moves — the item analog of the track tag family. Same -// FOREVER-STABLE contract (suffix composed with the channel prefix) — NEVER change these. +// Item-level mode moves — the item analog of the track tag family above. constexpr const char* kIdMoveItemsDesign = "VIEW_MOVE_ITEMS_DESIGN"; constexpr const char* kIdMoveItemsArrange = "VIEW_MOVE_ITEMS_ARRANGE"; constexpr const char* kIdUntagItems = "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). +// Not owned here (main.cpp owns g_session). ReaSamplerSession* g_session = nullptr; -// Minted command ids (0 until registration succeeds). Compared in the handler. int g_cmdToggleMode = 0; int g_cmdActivateArrange = 0; int g_cmdActivateDesign = 0; @@ -102,9 +88,6 @@ gaccel_register_t g_accelMoveItemsDesign{}; gaccel_register_t g_accelMoveItemsArrange{}; gaccel_register_t g_accelUntagItems{}; -// Collects the canonical GUID keys of the current track selection. Empty if nothing -// is selected. CountSelectedTracks/GetSelectedTrack ignore the master (SDK), which is -// exactly right — the master is never a tagged leaf. std::vector selectedTrackGuids() { std::vector guids; const int n = CountSelectedTracks(nullptr); // nullptr = active project @@ -118,22 +101,17 @@ std::vector selectedTrackGuids() { return guids; } -// Reapplies the model's CURRENT active mode to the active project so a membership -// mutation takes visible effect immediately (park/unpark/re-derive parents). Called -// after every tag/untag/show-both. `proj = nullptr` -> REAPER's active project. +// Reapplies the CURRENT active mode so a membership mutation takes visible effect +// immediately (park/unpark/re-derive parents). 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; +constexpr int kFreeModeFixedLanes = 2; // I_FREEMODE value; mirrors the shell's constant -// 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. +// Each selected item's GUID plus whether it sits on a MANUAL lane (EXEMPT — never +// retagged/re-laned). The lane name is read only on a fixed-lane track; on a normal +// track the shared predicate returns false for an empty name, so the read is skipped. // Items whose GUID cannot be read are dropped (an empty GUID must never be retagged). std::vector selectedRetagItems() { std::vector items; @@ -148,33 +126,18 @@ std::vector selectedRetagItems() { MediaTrack* tr = GetMediaItemTrack(it); const bool fixedLane = tr && static_cast(GetMediaTrackInfo_Value(tr, "I_FREEMODE")) == kFreeModeFixedLanes; - // Only read the lane name on a fixed-lane track; the pure predicate handles the - // normal-track case (returns false) so we pass an empty name and skip the read. const std::string laneNm = fixedLane ? itemLaneName(tr, it) : std::string{}; items.push_back(RetagItem{std::move(g), isOnManualLane(fixedLane, laneNm)}); } return items; } -// Persists both the bank and the Design-View model to the active project's ext -// state. Called after every state-changing Design View action so the view model -// is not lost across save/close/reopen. Marking the project dirty is correct — -// a Design View mutation is a project-level change the user should be prompted -// to save. -// -// When the membership index is non-empty AND the project is unsaved, we prompt -// the user to Save-As before persisting — mirroring the flow capture uses. -// Gate: if membership is empty (no tracks tagged), skip the prompt entirely; -// saveToActiveProject will no-op for an unsaved project, which is correct. -// -// DAW-ONLY ASSUMPTION: Main_SaveProject(proj, true) opens a Save-As dialog and -// blocks until the user dismisses it. The blocking behaviour and dialog -// appearance can only be confirmed in a running REAPER (same caveat as capture). +// Persists the bank + Design-View model after every state-changing action so the +// view model is not lost across save/close/reopen. If membership is non-empty and +// the project is unsaved, prompts Save-As first (mirrors the flow capture uses) — +// DAW-ONLY: Main_SaveProject(proj, true) blocks until the dialog is dismissed. void persistViewState() { if (!g_session->view().membership().empty()) { - // At least one track is tagged — worth persisting. Check whether the - // project is saved and, if not, prompt Save-As so saveToActiveProject - // can write ext state. Mirrors capture's readRppPath idiom exactly. ReaProject* proj = EnumProjects(-1, nullptr, 0); if (proj) { auto readRppPath = [&]() -> std::string { @@ -184,15 +147,11 @@ void persistViewState() { }; if (readRppPath().empty()) { - // Project is unsaved — prompt Save-As. Main_SaveProject(proj, true); - // Re-read: still empty means the user cancelled. - if (readRppPath().empty()) { + if (readRppPath().empty()) { // still empty -> user cancelled ShowConsoleMsg( "ReaSampler: Design View state will not persist until " "the project is saved.\n"); - // The in-session tag state is left as-is — the mode change - // already applied and remains valid for this session. return; } } @@ -201,11 +160,8 @@ void persistViewState() { g_session->saveToActiveProject(); } -// -- Action bodies --------------------------------------------------------- - -// Toggle: cycle to the next mode in ordinal order (Arrange <-> Design with two -// seeds; scales to cycle-through-all for >2 modes with no change here). applyMode -// itself sets the model's active mode, so we only compute the target and apply. +// Cycle to the next mode in ordinal order. applyMode itself sets the model's active +// mode, so we only compute the target and apply. void doToggleMode() { const std::string target = nextModeId(g_session->view().modes(), g_session->view().activeModeId()); @@ -223,9 +179,8 @@ void doActivateMode(const std::string& modeId) { bankPanelInvalidate(); // repaint the footer [Arrange|Design] toggle immediately } -// Tag the selection's leaves into `modeId`, then reapply so the change is immediate. // tag() replaces any prior single-mode membership (a leaf lives in one mode; the -// cross-mode case is show-both), matching the D1 contract. +// cross-mode case is show-both). void doTag(const std::string& modeId) { for (const std::string& g : selectedTrackGuids()) g_session->view().membership().tag(g, modeId); @@ -233,9 +188,8 @@ void doTag(const std::string& modeId) { persistViewState(); } -// Untag the selection entirely (return each to the Arrange default). This is the -// shared body behind both "Untag selected" and "Tag -> Arrange" (Arrange = the -// absence of a tag), so the two actions are the same act by definition. +// Shared body behind "Untag selected" and "Tag -> Arrange" — Arrange is the absence +// of a tag, so the two actions are the same act. void doUntag() { for (const std::string& g : selectedTrackGuids()) g_session->view().membership().untag(g); @@ -243,11 +197,8 @@ void doUntag() { persistViewState(); } -// Toggle the per-track show-both pin for the selection. Read the CURRENT pin of each -// track and flip it independently (a mixed selection converges toward "all on" then -// "all off" only if uniform; per-track flip is the honest semantics of a toggle on a -// multi-selection). show-both leaves are never parked (D1), so reapply reflects the -// change immediately. +// Flips each track's pin independently — the honest semantics of a toggle on a +// multi-selection (a mixed selection converges toward uniform only if it already was). void doShowBoth() { MembershipIndex& m = g_session->view().membership(); for (const std::string& g : selectedTrackGuids()) @@ -256,19 +207,12 @@ void doShowBoth() { persistViewState(); } -// -- Item-level mode moves (D2 Wave 3-B) ----------------------------------- +// Retag the current ITEM selection to `targetMode` (empty => untag -> Arrange +// default). planItemRetag decides which items to retag (manual-lane items are +// EXEMPT), upholding the managed-lanes-only invariant. Wrapped in ONE Undo block. // -// 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). +// UNDO/SAVE ordering: persistViewState may pop a Save-As dialog, which must NOT sit +// inside the Undo block, so we close the block first, then persist. void doMoveItems(const std::string& targetMode) { const std::vector selected = selectedRetagItems(); const std::vector ops = planItemRetag(selected, targetMode); @@ -277,15 +221,12 @@ void doMoveItems(const std::string& targetMode) { 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). + // Re-drive the same minting/apply path auto-tag uses: mint/split lanes for any + // track whose items now span modes, then reassert active-mode lane visibility. mintManagedLanes(g_session->view(), nullptr); reapplyActiveMode(); @@ -303,8 +244,6 @@ void doMoveItems(const std::string& targetMode) { void designViewRegisterActions(reaper_plugin_info_t* rec, ReaSamplerSession* session) { g_session = session; - // command_id -> gaccel for each. The single hookcommand that routes these lives - // in main.cpp (one hook per extension); designViewHandleCommand services them. g_cmdToggleMode = registerAction(rec, kIdToggleMode, g_accelToggleMode, "toggle Design View mode"); g_cmdActivateArrange = registerAction(rec, kIdActivateArrange, g_accelActivateArrange, @@ -320,7 +259,6 @@ void designViewRegisterActions(reaper_plugin_info_t* rec, ReaSamplerSession* ses g_cmdShowBoth = registerAction(rec, kIdShowBoth, g_accelShowBoth, "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, "move selected items -> Design"); g_cmdMoveItemsArrange = registerAction(rec, kIdMoveItemsArrange, g_accelMoveItemsArrange, @@ -336,13 +274,10 @@ bool designViewHandleCommand(int command) { if (command == g_cmdActivateArrange) { doActivateMode(kArrangeModeId); return true; } if (command == g_cmdActivateDesign) { doActivateMode(kDesignModeId); return true; } if (command == g_cmdTagDesign) { doTag(kDesignModeId); return true; } - // Tag -> Arrange and Untag are the same act (Arrange = the absence of a tag). if (command == g_cmdTagArrange) { doUntag(); return true; } 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; } @@ -351,11 +286,8 @@ bool designViewHandleCommand(int command) { } 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 — the item - // moves registered last, so they tear down first). - // Each '-command_id' re-presents the SAME interned, channel-qualified id (channelIdFor - // returns the memoized pointer registered above), so the unregister matches exactly. + // Reverse registration order; each '-command_id' re-presents the SAME interned + // pointer channelIdFor returned above. rec->Register("-gaccel", (void*)&g_accelUntagItems); rec->Register("-command_id", (void*)channelIdFor(kIdUntagItems)); rec->Register("-gaccel", (void*)&g_accelMoveItemsArrange); diff --git a/src/shell/actions/design_view_actions.h b/src/shell/actions/design_view_actions.h index 4006516..6352feb 100644 --- a/src/shell/actions/design_view_actions.h +++ b/src/shell/actions/design_view_actions.h @@ -1,38 +1,26 @@ #pragma once -// design_view_actions — the Design View action family (Phase D4; Q-W4 split of -// actions.h). Registers the bindable actions that drive the mode workflow and wires -// them end-to-end: toggle/activate a mode, tag/untag/show-both the current track -// selection, and the item-level mode moves (D2 W3-B). Each action mutates the -// session's ViewModeModel (D1, via persist's ReaSamplerSession) and then reapplies -// the active mode through the view shell (D2) so the change takes effect immediately. -// -// REAPER-facing shell: the .cpp includes reaper_plugin_functions.h WITHOUT -// REAPERAPI_IMPLEMENT (main.cpp owns the API pointers — CLAUDE.md §contract). This -// header is SDK-free; main.cpp calls register/handle/unregister and nothing else. +// design_view_actions — the Design View bindable action family: toggle/activate a +// mode, tag/untag/show-both the current track selection, and the item-level mode +// moves. Each action mutates the session's ViewModeModel then reapplies the active +// mode through the view shell so the change takes effect immediately. SDK-free +// header; main.cpp calls register/handle/unregister and nothing else. -// Forward-declared at GLOBAL scope (matches reaper_plugin.h's typedef struct -// reaper_plugin_info_t) so this header stays SDK-free; the .cpp includes the real -// definition. Declared before the namespace so it is the global type, not a -// namespace-local shadow. +// Forward-declared at GLOBAL scope (matches reaper_plugin.h's typedef) so this +// header stays SDK-free; the .cpp includes the real definition. struct reaper_plugin_info_t; namespace reasampler { class ReaSamplerSession; -// Registers the Design View action family against `rec` (command_id + gaccel + -// hookcommand-routing is owned by the caller's single hookcommand). `session` is the -// live session the actions mutate; it must outlive the registration. Idempotent is -// NOT promised — call exactly once at load, mirror-unregister once at unload. +// `session` must outlive registration. Not idempotent — call exactly once at load, +// mirror-unregister once at unload. void designViewRegisterActions(reaper_plugin_info_t* rec, ReaSamplerSession* session); -// Services one fired command. Returns true iff `command` is one of this module's -// action ids (and it was handled); false otherwise so the caller's hookcommand keeps -// looking (per the contract: claim only our own ids). Safe to call for any command. +// True iff `command` is one of this module's ids (and handled); false otherwise so +// the caller's hookcommand keeps looking. bool designViewHandleCommand(int command); -// Mirror-unregisters everything designViewRegisterActions registered, with the -// '-'-prefixed strings (per the contract's unload rule). Call once on rec==nullptr. void designViewUnregisterActions(reaper_plugin_info_t* rec); } // namespace reasampler diff --git a/src/shell/actions/drag_out_win.cpp b/src/shell/actions/drag_out_win.cpp index 8316a86..71b92f1 100644 --- a/src/shell/actions/drag_out_win.cpp +++ b/src/shell/actions/drag_out_win.cpp @@ -1,13 +1,7 @@ -// drag_out_win — OS/COM initiation of native OS drag-out (M11). See drag_out_win.h. -// -// Windows path (primary): a hand-rolled minimal IDataObject exposing exactly one format, -// CF_HDROP, plus a minimal IDropSource, handed to OLE DoDragDrop with a COPY-ONLY effect -// mask. We roll our own rather than pull in a helper because the object is tiny (one -// format, one medium) and the copy-only guarantee must be structural and auditable in one -// place. mac/linux route to SWELL's file-list drag behind the same seam. -// -// Compiled into the reaper_reasampler MODULE. No REAPER API is used here (pure OS/COM); it -// is a leaf the bank_panel calls. +// drag_out_win.cpp — see drag_out_win.h. Hand-rolled IDataObject/IDropSource rather +// than a helper library: the object is tiny (one format, one medium) and the +// copy-only guarantee must be structural and auditable in one place. No REAPER API +// used here (pure OS/COM). #include "shell/actions/drag_out_win.h" @@ -29,8 +23,8 @@ namespace { HGLOBAL buildHDrop(const std::vector& paths) { if (paths.empty()) return nullptr; - // 1) Convert each UTF-8 path to wide, normalizing '/' -> '\\' (the panel stores paths - // slash-normalized for its own resolution; CF_HDROP wants native backslashes). + // Convert each UTF-8 path to wide, normalizing '/' -> '\\' (the panel stores paths + // slash-normalized; CF_HDROP wants native backslashes). std::vector wide; wide.reserve(paths.size()); std::size_t totalChars = 0; // characters incl. each path's terminating NUL @@ -40,8 +34,7 @@ HGLOBAL buildHDrop(const std::vector& paths) { if (need <= 0) continue; // unconvertible path — skip rather than emit garbage std::wstring w(static_cast(need), L'\0'); MultiByteToWideChar(CP_UTF8, 0, p.c_str(), -1, &w[0], need); - // `need` includes the NUL; drop it from the string length, we re-add it in the buffer. - if (!w.empty() && w.back() == L'\0') w.pop_back(); + if (!w.empty() && w.back() == L'\0') w.pop_back(); // re-added below for (wchar_t& c : w) if (c == L'/') c = L'\\'; totalChars += w.size() + 1; // + the per-path NUL wide.push_back(std::move(w)); @@ -200,10 +193,9 @@ private: bool initiateDragOut(HWND__* /*panelHwnd*/, const std::vector& absolutePaths) { if (absolutePaths.empty()) return false; - // REAPER's main thread is already OLE-initialized (it hosts OLE drag targets), so we do - // NOT call OleInitialize here — a nested OleInitialize on an already-initialized STA is - // harmless-but-unnecessary, and OleUninitialize pairing across a REAPER-owned apartment - // is the kind of thing that bites. DoDragDrop works on the already-initialized STA. + // REAPER's main thread is already OLE-initialized (it hosts OLE drag targets); we + // deliberately do NOT call OleInitialize — pairing OleUninitialize across a + // REAPER-owned apartment is the kind of thing that bites. HGLOBAL hdrop = buildHDrop(absolutePaths); if (!hdrop) return false; @@ -211,9 +203,8 @@ bool initiateDragOut(HWND__* /*panelHwnd*/, const std::vector& abso auto* source = new DropSource(); DWORD effect = 0; - // COPY-ONLY (invariant #1): the allowed-effects mask is DROPEFFECT_COPY alone. MOVE is - // NEVER offered, so no drop target can relocate (delete) the bank file — only prune - // deletes bank bytes (Phase R boundary). + // COPY-ONLY: the allowed-effects mask is DROPEFFECT_COPY alone. MOVE is never + // offered, so no drop target can relocate (delete) the bank file. const HRESULT hr = DoDragDrop(data, source, DROPEFFECT_COPY, &effect); source->Release(); @@ -232,12 +223,9 @@ bool initiateDragOut(HWND__* /*panelHwnd*/, const std::vector& abso namespace reasampler { -// SWELL provides a file-list drag surface (SWELL_InitiateDragDropOfFileList, verified in -// vendor/WDL/WDL/swell/swell-functions.h). It takes a C-string array + count and initiates -// a copy-style file drag from the given window. Unlike OLE it exposes no per-source effect -// mask, so the copy-only guarantee rests on SWELL's copy semantics rather than an explicit -// DROPEFFECT_COPY mask — an honest platform difference, not a faked equivalence. Windows is -// the exact-control path (D5: Windows is the shipping target). +// SWELL_InitiateDragDropOfFileList initiates a copy-style file drag from the given +// window. Unlike OLE it exposes no per-source effect mask, so the copy-only +// guarantee here rests on SWELL's copy semantics rather than an explicit mask. bool initiateDragOut(HWND__* panelHwnd, const std::vector& absolutePaths) { if (absolutePaths.empty() || !panelHwnd) return false; diff --git a/src/shell/actions/drag_out_win.h b/src/shell/actions/drag_out_win.h index 04ccb6b..1179a7c 100644 --- a/src/shell/actions/drag_out_win.h +++ b/src/shell/actions/drag_out_win.h @@ -1,25 +1,17 @@ #pragma once -// drag_out_win — the OS/COM initiation half of native OS drag-out (Milestone 11). The pure -// gesture-boundary decision and path-list assembly live in drag_out.*; THIS is the platform -// shell that hands a resolved, existing-file path list to the operating system's drag-drop -// machinery so the user can drop bank samples into Explorer / another app / another DAW. +// drag_out_win — the OS/COM initiation half of native OS drag-out: hands a resolved +// existing-file path list to the OS's drag-drop machinery so the user can drop bank +// samples into Explorer / another app / another DAW. The pure gesture-boundary +// decision + path-list assembly live in drag_out.*. // -// ONE seam, platform-forked inside the .cpp: -// * Windows (primary — Daniel's target): OLE DoDragDrop with a minimal IDataObject -// carrying CF_HDROP (absolute paths, double-null-terminated wide list) and a minimal -// IDropSource. COPY-ONLY is STRUCTURAL: the IDataObject offers DROPEFFECT_COPY and the -// effect mask passed to DoDragDrop is DROPEFFECT_COPY alone — MOVE is never offered, so -// no target can pull the bank file out of the bank folder (invariant #1: a move would -// delete bank bytes, and per the Phase R boundary ONLY prune deletes files). -// * macOS/Linux (SWELL): SWELL_InitiateDragDropOfFileList (verified present in -// vendor/WDL/WDL/swell/swell-functions.h) behind the same seam. SWELL's file-list drag -// is a copy-style file drag; it exposes no per-source effect mask the way OLE does, so -// the copy-only guarantee there rests on SWELL's copy semantics rather than an explicit -// mask — noted honestly, not faked. Windows is where the mask control is exact. +// COPY-ONLY is STRUCTURAL on Windows: DoDragDrop's effect mask is DROPEFFECT_COPY +// alone — MOVE is never offered, so no target can pull a file out of the bank folder +// (only prune deletes bank bytes). macOS/Linux route through SWELL's file-list drag, +// which exposes no per-source effect mask, so there the copy-only guarantee rests on +// SWELL's copy semantics rather than an explicit mask. // -// NON-DESTRUCTIVE (invariant #2): initiating a drag reads nothing but the path list and -// mutates no sample / index / selection. A cancelled or failed drag changes nothing — the -// OS layer here neither writes ext-state nor touches the book. +// NON-DESTRUCTIVE: initiating a drag reads nothing but the path list; a cancelled or +// failed drag mutates no sample/index/selection. #include #include @@ -28,16 +20,10 @@ struct HWND__; // avoid dragging windows.h into every includer; the shell casts namespace reasampler { -// Initiates a native OS drag-out of `absolutePaths` (already resolved, existing, de-duped — -// the pure drag_out::assemblePathList output) from the panel window `panelHwnd`. COPY-ONLY; -// see the header note. A no-op when the path list is empty (nothing draggable — the caller -// checks this too, but the guard is repeated here so a direct call is safe). -// -// BLOCKING on Windows: OLE DoDragDrop runs its own modal message loop until the drop or -// cancel, then returns — the caller's gesture state should be reset AFTER this returns. -// Returns true if a drop was accepted (DROPEFFECT_COPY), false on cancel / failure / -// empty input. The return is advisory (a failed drag is visible by nothing happening — -// the caller does not surface an error, per the brief's no-console-output constraint). +// `absolutePaths` must already be resolved/existing/de-duped (drag_out::assemblePathList +// output). No-op when empty. BLOCKING on Windows: OLE DoDragDrop runs its own modal +// message loop until drop/cancel. Returns true iff the drop was accepted +// (DROPEFFECT_COPY); the return is advisory — a failed drag surfaces no error. bool initiateDragOut(HWND__* panelHwnd, const std::vector& absolutePaths); } // namespace reasampler diff --git a/src/shell/actions/instrument_drop_win.cpp b/src/shell/actions/instrument_drop_win.cpp index fa2503d..b6b0213 100644 --- a/src/shell/actions/instrument_drop_win.cpp +++ b/src/shell/actions/instrument_drop_win.cpp @@ -1,7 +1,5 @@ -// instrument_drop_win — the REAPER shell for S17 drop-and-load. See instrument_drop_win.h. -// -// Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.h WITHOUT -// REAPERAPI_IMPLEMENT (main.cpp owns the pointers; here they are extern via the WANT list). +// instrument_drop_win.cpp — see instrument_drop_win.h. main.cpp owns the API +// pointers; this TU gets them extern via the WANT list. #include "shell/actions/instrument_drop_win.h" @@ -35,32 +33,24 @@ using wire::infoNamesFxHotspot; namespace { -// Write `bytes` to a fresh uniquely-named .vstpreset in the OS temp dir and return its path; -// returns an empty path on any failure. The .vstpreset extension is load-bearing — -// TrackFX_SetPreset's full-path form is documented for .vstpreset files (VST3). The file is -// transient: the caller deletes it right after the SetPreset call. +// Writes `bytes` to a fresh uniquely-named .vstpreset in the OS temp dir; empty path +// on any failure. The .vstpreset extension is load-bearing — TrackFX_SetPreset's +// full-path form is documented for .vstpreset files (VST3). Transient: the caller +// deletes it right after the SetPreset call. // -// The temp filename embeds the process ID so two concurrent REAPER instances (e.g. stable + -// beta) cannot collide in the shared OS temp dir, and one instance's cleanup cannot -// accidentally delete another's in-flight file. -// -// Non-throwing: every std::filesystem call uses the error_code overload. The whole body is -// wrapped in try/catch to guarantee no exception crosses the REAPER C callback boundary -// (the same discipline the prune shell uses — see prune_fs.cpp's non-throwing scan comment). -// -// Returns the path object (not a narrow string) so the caller can: -// (a) pass path.u8string() to TrackFX_SetPreset — UTF-8 on MSVC, not ACP-converted, -// so a temp dir with accented or CJK user-name bytes is handled correctly; -// (b) delete via the retained path object — not via re-parsing the narrow string — -// so the cleanup cannot leak if the conversion above were to round-trip incorrectly. +// Non-throwing: every std::filesystem call uses the error_code overload, and the +// whole body is try/catch-wrapped so no exception crosses the REAPER callback +// boundary. Returns the path object (not a narrow string) so the caller can pass +// path.u8string() to TrackFX_SetPreset (UTF-8, not ACP-converted) and delete via the +// same retained path — never a re-parsed narrow string. std::filesystem::path writeTempPreset(const std::vector& bytes) { try { static std::atomic counter{0}; std::error_code ec; const std::filesystem::path dir = std::filesystem::temp_directory_path(ec); if (ec) return {}; - // PID in the name keeps files from distinct REAPER instances distinct in the shared - // temp dir — prevents cross-instance collisions and spurious post-apply deletions. + // PID in the name: two concurrent REAPER instances (stable + beta) cannot + // collide in the shared temp dir. const std::string name = "reasampler_drop_" + std::to_string(GetCurrentProcessId()) + "_" + std::to_string(counter.fetch_add(1)) + ".vstpreset"; @@ -85,10 +75,8 @@ std::filesystem::path writeTempPreset(const std::vector& bytes) { FxDropTarget resolveFxDropTarget(int screenX, int screenY) { FxDropTarget out; char info[256] = {0}; - // GetThingFromPoint returns the track under the point (may be null for a non-track thing) - // and fills `info` with what was hit. A non-empty info OR a non-null track means the point - // is over REAPER's own UI; a null track with an empty info means the pointer has left - // REAPER entirely (over another app / the desktop) — the OsDrag boundary. + // A non-empty info OR a non-null track means the point is over REAPER's own UI; + // a null track with empty info means the pointer has left REAPER entirely. MediaTrack* track = GetThingFromPoint(screenX, screenY, info, sizeof(info)); out.track = track; out.overReaperUi = (track != nullptr) || (info[0] != '\0'); @@ -101,46 +89,32 @@ FxDropTarget resolveFxDropTarget(int screenX, int screenY) { bool loadInstrumentOntoTrack(MediaTrack* track, const std::vector& presetBytes) { if (!track || presetBytes.empty()) return false; - // Materialize the .vstpreset FIRST so an I/O failure leaves the track untouched (no FX - // added yet — nothing to roll back). + // Materialize the .vstpreset FIRST so an I/O failure leaves the track untouched + // (no FX added yet — nothing to roll back). const std::filesystem::path presetPath = writeTempPreset(presetBytes); if (presetPath.empty()) return false; - // The CHANNEL-correct FX name: "VST3:ReaSampler 9000" on stable, "VST3:ReaSampler 9000 - // beta" on beta. Sourcing it from app_version::vstPluginName() (the same accessor the VST - // factory display name derives from) keeps the pairing invariant intact — a beta extension - // drops the beta VST, a stable extension the stable VST — with no literal to drift. (The - // preset's class ID forks by the same channel bit inside buildInstrumentDropPreset.) + // Channel-correct FX name ("VST3:ReaSampler 9000[ beta]") sourced from the same + // accessor the VST factory display name derives from, so the pairing invariant + // (beta extension <-> beta VST) has no literal to drift. const std::string fxName = "VST3:" + vstPluginName(); - // Negative `instantiate` => always create a NEW instance (verified in the header). recFX - // = false: a normal track FX chain instance, not a record/monitoring FX. + // Negative `instantiate` => always create a NEW instance. recFX = false: a + // normal track FX chain instance, not a record/monitoring FX. const int fxIndex = TrackFX_AddByName(track, fxName.c_str(), /*recFX=*/false, /*instantiate=*/-1); bool ok = fxIndex >= 0; - // Apply the dragged capture's component state through the DOCUMENTED channel: a full - // .vstpreset path handed to TrackFX_SetPreset (SDK: "Full paths to .vstpreset files are - // also supported for VST3 plug-ins"). REAPER parses the Steinberg container and feeds the - // 'Comp' chunk to the instance's setState — the same bytes the instrument's own - // serializer produced (instrument_drop::buildInstrumentDropPreset -> - // sample_map::serializeComponentState). Unlike the former "vst_chunk" named-config-parm - // write, a failure here is REPORTED (false), not silently ignored. - // - // u8string() gives UTF-8 bytes on MSVC (not ACP-converted), so a temp dir under an - // accented or CJK user-name is handled correctly by REAPER's path APIs. + // u8string() gives UTF-8 bytes on MSVC (not ACP-converted), so a temp dir under + // an accented or CJK user-name is handled correctly by REAPER's path APIs. if (ok) ok = TrackFX_SetPreset(track, fxIndex, presetPath.u8string().c_str()); - // The preset file is transient regardless of outcome; delete via the retained path object - // (not a re-parsed narrow string) so cleanup cannot leak even if the UTF-8 conversion - // round-trip were incorrect. std::error_code ec; - std::filesystem::remove(presetPath, ec); + std::filesystem::remove(presetPath, ec); // transient regardless of outcome + // All-or-nothing: if the preset apply fails, remove the FX instance we just + // added so the track is left exactly as it was. if (!ok && fxIndex >= 0) { - // All-or-nothing: if the preset apply fails, remove the empty FX instance we just - // added so the track is left exactly as it was. TrackFX_Delete signature (verified - // in reaper_plugin_functions.h:7236): bool TrackFX_Delete(MediaTrack*, int fx). TrackFX_Delete(track, fxIndex); } return ok; @@ -149,11 +123,8 @@ bool loadInstrumentOntoTrack(MediaTrack* track, const std::vector& bool performInstrumentDrop(MediaTrack* track, const std::vector& presetBytes) { if (!track || presetBytes.empty()) return false; - // One undo point for the whole gesture (mirrors the bank-verb undo discipline). Both the - // FX add and the state apply are REAPER-undoable, so Ctrl-Z removes the instance cleanly. Undo_BeginBlock2(nullptr); const bool ok = loadInstrumentOntoTrack(track, presetBytes); - // The undo label reflects the placement-of-the-player framing (not a capture, not an insert). Undo_EndBlock2(nullptr, "ReaSampler: drop capture onto FX chain", -1); return ok; } diff --git a/src/shell/actions/instrument_drop_win.h b/src/shell/actions/instrument_drop_win.h index 20a65cb..5e4e675 100644 --- a/src/shell/actions/instrument_drop_win.h +++ b/src/shell/actions/instrument_drop_win.h @@ -1,69 +1,51 @@ #pragma once -// instrument_drop_win — the REAPER-facing shell half of S17 drop-and-load. The pure gesture -// decision lives in drag_out (DragGesture::InstrumentDrop) and the pure payload construction -// in instrument_drop; THIS is the platform shell that (a) resolves a screen point to a track -// + its FX-surface hotspot via REAPER's hit-test API, and (b) on release adds a ReaSampler -// 9000 instance to that track and applies the dragged capture as its component state via a -// temp .vstpreset + TrackFX_SetPreset (S-GA-DropFX: the earlier "vst_chunk" named-config-parm -// write was silently unappliable — see instrument_drop.h for the diagnosis). +// instrument_drop_win — the REAPER-facing shell half of drop-and-load: (a) resolves +// a screen point to a track + its FX-surface hotspot via REAPER's hit-test API, and +// (b) on release adds a ReaSampler 9000 instance and applies the dragged capture as +// its component state via a temp .vstpreset + TrackFX_SetPreset (the earlier +// "vst_chunk" named-config-parm write was silently unappliable for VST3 — don't +// revert to it). The pure gesture decision lives in drag_out; the pure payload +// construction in instrument_drop. // -// Compiled into the reaper_reasampler MODULE. REAPER-facing (GetThingFromPoint, TrackFX_*, -// Undo_*), so DAW-verified, not unit-tested; the pure decision + preset it drives are CTest'd. -// -// LOAD-BEARING (CONTEXT.md §Drop-and-load): this is an EXPLICIT user placement-of-the-player -// gesture — it adds a READER of the bank on a track and points it at one already-captured -// sample. It NEVER captures, NEVER writes the bank, and NEVER inserts a timeline item. The -// only writes are: a new FX instance on the target track + that instance's own component -// state — both REAPER-undoable, wrapped in one undo block so the whole gesture is one Ctrl-Z -// — plus a transient .vstpreset in the OS temp dir, deleted before returning. +// LOAD-BEARING: an EXPLICIT user placement-of-the-player gesture — adds a READER of +// the bank on a track, pointed at an already-captured sample. NEVER captures, NEVER +// writes the bank, NEVER inserts a timeline item. The only writes are a new FX +// instance + its component state, both wrapped in one undo block (one Ctrl-Z), plus +// a transient .vstpreset deleted before returning. #include #include -// Opaque REAPER track handle at the boundary so includers don't need the SDK. The SDK -// declares it as a class (reaper_plugin.h) — match that spelling so the mangled name agrees. +// Declared as a class (matching reaper_plugin.h) so the mangled name agrees, without +// pulling in the SDK. class MediaTrack; namespace reasampler { -// The result of hit-testing a screen point during a live InstrumentDrop drag. struct FxDropTarget { MediaTrack* track = nullptr; // the track under the pointer (null if none / not a track) bool overReaperUi = false; // the point is over REAPER's own window/UI at all bool overFxHotspot = false; // specifically over this track's FX button/chain surface - // A valid drop target: a resolved track whose FX hotspot is under the pointer. bool valid() const { return track != nullptr && overFxHotspot; } }; -// Hit-test a screen point (REAPER screen coords) to an FX drop target. Wraps -// GetThingFromPoint, whose info string tells us what was hit ("tcp.fx*"/"mcp.fx*" for the -// TCP/MCP FX button family; "fx_chain"/"fx_N" for the FX-chain and floating-FX windows; bare -// "tcp"/"mcp" or other sub-element tokens for non-FX track-panel regions). `overReaperUi` is -// the shell-supplied predicate the pure drag_out::decideGesture consumes (true when the point -// is over REAPER's own UI — i.e. GetThingFromPoint returned a track OR a recognizable -// non-track thing, false when the pointer has left REAPER entirely). `overFxHotspot` is true -// only when the info string names a genuine FX-bearing surface — decided by the pure -// instrument_drop::infoNamesFxHotspot from the SDK's own hit-test string. +// Wraps GetThingFromPoint, whose info string tells us what was hit ("tcp.fx*"/ +// "mcp.fx*" for the TCP/MCP FX button family; "fx_chain"/"fx_N" for the FX-chain and +// floating windows). `overReaperUi` is true when the point is over REAPER's own UI +// at all; `overFxHotspot` is true only for a genuine FX-bearing surface (decided by +// the pure instrument_drop::infoNamesFxHotspot). FxDropTarget resolveFxDropTarget(int screenX, int screenY); -// Perform the drop on `track`: add a fresh ReaSampler 9000 instance and apply `presetBytes` -// (the instrument_drop::buildInstrumentDropPreset output — a .vstpreset image) as its -// component state so it plays the dragged capture. Wraps the add + apply in one REAPER undo -// block (mirrors the bank-verb undo discipline). Returns true on success (the FX was added -// and the preset applied), false on any failure. All-or-nothing: if the preset apply fails -// after a successful add, the freshly-added FX instance is removed via TrackFX_Delete before -// returning false, leaving the track exactly as it was (no orphaned empty-state FX). -// NEVER inserts a timeline item; the ONLY persistent mutations are the FX instance + its -// state, both undoable. +// Adds a fresh ReaSampler 9000 instance to `track` and applies `presetBytes` as its +// component state. Wraps add + apply in one REAPER undo block. All-or-nothing: if +// the preset apply fails after a successful add, the FX instance is removed via +// TrackFX_Delete before returning false, leaving the track exactly as it was. bool performInstrumentDrop(MediaTrack* track, const std::vector& presetBytes); -// Add a fresh ReaSampler 9000 instance to `track` and apply `presetBytes` as its component -// state. Same all-or-nothing add+apply contract as performInstrumentDrop (rolls the FX back -// via TrackFX_Delete on apply failure), but does NOT open its own undo block — the caller owns -// the undo grouping so the whole gesture (persist + FX-add + apply) collapses to -// one Ctrl-Z. This is the shared inner half performInstrumentDrop wraps in its own block. -// Returns true on success, false on any failure. NEVER inserts a timeline item. +// Same all-or-nothing add+apply contract as performInstrumentDrop but does NOT open +// its own undo block — the caller owns the undo grouping so persist + FX-add + apply +// collapses to one Ctrl-Z. The shared inner half performInstrumentDrop wraps. bool loadInstrumentOntoTrack(MediaTrack* track, const std::vector& presetBytes); } // namespace reasampler diff --git a/src/shell/actions/prune_action.cpp b/src/shell/actions/prune_action.cpp index a0b037d..0fa4240 100644 --- a/src/shell/actions/prune_action.cpp +++ b/src/shell/actions/prune_action.cpp @@ -1,8 +1,5 @@ -// prune_action.cpp — the "Prune bank folder" action body (Phase R3; Q-W4 split of -// actions.cpp). See prune_action.h for the contract this TU preserves. -// -// Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.h -// WITHOUT REAPERAPI_IMPLEMENT (main.cpp owns the API pointers — CLAUDE.md §contract). +// prune_action.cpp — see prune_action.h for the contract this TU preserves. +// main.cpp owns the API pointers; this TU gets them extern. #include "shell/actions/prune_action.h" @@ -18,25 +15,17 @@ namespace reasampler { -// Prune bank folder — Phase R (Reclaim), R3: the guarded DESTRUCTIVE step, and the SOLE -// file-deletion entry in ReaSampler. Dry-run FIRST (compute the orphan set, read-only), -// then — only when orphans exist — a blocking CONFIRM showing the SPECIFIC manifest -// (count + reclaimable bytes + the file list, truncated consistent with the 64-cap), then -// on explicit Yes delete EXACTLY that set (session.pruneReclaim, which recomputes the -// pure core fresh and deletes confirmed ∩ freshOrphans — trash-preferred, unlink fallback). -// Zero orphans => informational only, NO confirm ever shown. Cancel deletes nothing. -// -// The full (untruncated) orphan set is captured here for the delete; the dry-run's -// truncated list is only the confirm's readout. No ext-state is written and no undo point -// is opened (file deletion is not REAPER-undoable and pruneReclaim mutates no project -// state) — a Ctrl-Z after a prune correctly cannot claim to restore deleted files. +// The guarded DESTRUCTIVE step and the SOLE file-deletion entry in ReaSampler. +// Dry-run FIRST (read-only); only when orphans exist, a blocking CONFIRM with the +// manifest; on explicit Yes, delete EXACTLY that set (recomputed fresh — confirmed ∩ +// freshOrphans). Zero orphans => informational only, no confirm shown. No ext-state +// write, no undo point (file deletion is not REAPER-undoable). void doBankPruneFolder(ReaSamplerSession& session) { const reclaim::PruneReport report = session.pruneDryRun(); - // pS-usage FAIL-SAFE: a present instance-usage record could not be read — the - // protected set is unknowable, so the prune HALTS outright (deletes nothing) rather - // than proceed with degraded protection. Distinct from "no orphans": the user must - // know the prune refused to run and why. + // FAIL-SAFE: an unreadable instance-usage record makes the protected set + // unknowable, so the prune HALTS outright rather than proceed with degraded + // protection. if (report.abortedUnreadableUsage) { std::string msg = "ReaSampler prune: ABORTED -- one or more instance usage records could not " @@ -58,13 +47,10 @@ void doBankPruneFolder(ReaSamplerSession& session) { return; } - // The EXACT set the delete will target — full, untruncated, so what the confirm - // summarises (count + bytes) matches what pruneReclaim reclaims. Captured before the - // confirm so the confirm and the delete reason about the same enumeration. + // The EXACT (untruncated) set the delete will target, captured before the confirm + // so confirm and delete reason about the same enumeration. const std::vector orphanSet = session.pruneOrphanSet(); - // Confirm-with-manifest: count + bytes exact; the file list is the dry-run's 64-capped - // list (the same clip the R2 readout used), with a "N more not shown" tail when clipped. std::string msg = "ReaSampler prune will PERMANENTLY reclaim " + std::to_string(report.count) + " orphaned file(s), freeing " + std::to_string(report.totalBytes) + " bytes.\n\n" @@ -84,7 +70,6 @@ void doBankPruneFolder(ReaSamplerSession& session) { return; } - // Confirmed -> delete exactly the confirmed set (recomputed fresh, stale entries skipped). const reclaim::PruneDeletionResult del = session.pruneReclaim(orphanSet); std::string done = "ReaSampler prune: reclaimed " + diff --git a/src/shell/actions/prune_action.h b/src/shell/actions/prune_action.h index fed17df..eae1a4e 100644 --- a/src/shell/actions/prune_action.h +++ b/src/shell/actions/prune_action.h @@ -1,15 +1,12 @@ #pragma once -// prune_action — the "Prune bank folder" action body (Phase R3; Q-W4 split of -// actions.cpp). This is the SOLE file-deletion action in ReaSampler, isolated in its -// own TU so the deletion authority is one obvious module on the actions side (its -// persist-side counterpart concentrates into prune_fs in Q-W5). Registration and -// hookcommand routing for its FOREVER-STABLE id (BANK_PRUNE_FOLDER) stay with the -// bank family (bank_actions) — one registration flow, one guarded body here. +// prune_action — the "Prune bank folder" action body: the SOLE file-deletion action +// in ReaSampler, isolated in its own TU so the deletion authority is one obvious +// module. Registration/dispatch for its FOREVER-STABLE id (BANK_PRUNE_FOLDER) stay +// with bank_actions; one guarded body here. // // Contract (preserve exactly): dry-run first; abort outright on unreadable usage -// records (pS-usage fail-safe); confirm-with-manifest before any deletion; opens NO -// undo point and writes NO ext state (file deletion is not REAPER-undoable). Routes -// to persist's public session API only (pruneDryRun / pruneOrphanSet / pruneReclaim). +// records (fail-safe); confirm-with-manifest before any deletion; opens NO undo +// point and writes NO ext state (file deletion is not REAPER-undoable). namespace reasampler { diff --git a/src/shell/bank_ops/bank_ops.cpp b/src/shell/bank_ops/bank_ops.cpp index 55dfe4e..134715e 100644 --- a/src/shell/bank_ops/bank_ops.cpp +++ b/src/shell/bank_ops/bank_ops.cpp @@ -1,16 +1,11 @@ -// bank_ops.cpp — the promptless bank-verb seam (Q-W6 lift; see bank_ops.h for the -// contract). The ONE implementation home of the bank verbs (create / rename / -// delete / evacuate / activate / move / copy / remove): each mutates the given -// session's book() then persists via persistBankOp() (one bank op = one Ctrl-Z; a -// true index no-op opens NO undo point). It DOES mutate the bank BOOK — but only -// the index/model + ext-state, never the arrange, never a sample file on disk -// (bank ops are index-only; files stay put — CONTEXT.md §Multi-bank). -// REFERENCE-INVALIDATION GUARDRAIL: after a STRUCTURAL mutation any -// Bank*/BankModel& is invalid — verbs take ids and resolve fresh per model call. +// bank_ops.cpp — see bank_ops.h for the contract. The ONE implementation home of the +// bank verbs: each mutates the given session's book() then persists via +// persistBankOp() (one bank op = one Ctrl-Z; a true index no-op opens NO undo +// point). Index/model + ext-state only — never the arrange, never a file on disk. +// REFERENCE-INVALIDATION GUARDRAIL: after a STRUCTURAL mutation any Bank*/BankModel& +// is invalid — verbs take ids and resolve fresh per model call. // -// Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.h -// WITHOUT REAPERAPI_IMPLEMENT — main.cpp owns the API pointers; here they are -// extern (CLAUDE.md §contract). DAW-verified, not unit tested. +// main.cpp owns the API pointers; this TU gets them extern. DAW-verified, not unit tested. #include "shell/bank_ops/bank_ops.h" @@ -31,9 +26,7 @@ namespace reasampler { namespace { -// Mints a fresh, genuine REAPER GUID string as a stable bank id (the B2/model -// design: ids are caller-supplied and stable; the model stays pure and mints none). -// Distinct from a track GUID by origin only — both are canonical guidToString output. +// Ids are caller-supplied and stable; the model stays pure and mints none. std::string mintBankId() { GUID g{}; genGuid(&g); @@ -44,35 +37,20 @@ std::string mintBankId() { } // namespace -// Persists a completed bank-index verb as a SINGLE batched REAPER undo point (R-B) — -// one bank op = one Ctrl-Z. +// WHY THIS WRAPS: a bank verb mutates ONLY our project ext-state, which REAPER's +// undo system captures iff UNDO_STATE_MISCCFG is set (the SDK documents MISCCFG as +// covering extensions' project ext-state). We pass exactly UNDO_STATE_MISCCFG, not +// -1/UNDO_STATE_ALL — a bank verb touches no tracks/FX/items, so snapshotting them +// would be both heavier and wrong. Persist runs INSIDE the block so the post-mutation +// ext-state is the block's "after" image. // -// WHY THIS WRAPS AND saveToActiveProject() DOES NOT: a bank verb mutates ONLY our -// project ext-state (SetProjExtState under "reasampler"), which REAPER's undo system -// captures iff UNDO_STATE_MISCCFG is set in the Undo_EndBlock2 flags — the SDK -// documents MISCCFG as covering "extensions!" project ext-state (reaper_plugin.h -// ~1544, ~1199). We pass exactly UNDO_STATE_MISCCFG (not -1 / UNDO_STATE_ALL as the -// item-move family does): a bank verb touches no tracks, FX, items, or envelopes, so -// snapshotting them would be both heavier and semantically wrong. The persist runs -// INSIDE the block so the post-mutation ext-state is the block's "after" image. -// -// UNSAVED-PROJECT GUARDRAIL: on an unsaved / no-active project saveToActiveProject() -// no-ops (nothing is written to ext state). We must still CLOSE the block we opened, -// but with an EMPTY label and a zero flag so REAPER DISCARDS the point instead of -// recording a no-effect undo entry — mirroring view.cpp's empty-plan close. The -// in-session model change stands and persists on the user's next save; it just earns -// no undo point until there is a project to persist into (undo of an unsaved bank op -// has nothing to roll back to anyway). The Begin/End must still be balanced, hence -// the close-either-way. (Quiet persist by design — mirrors the CAPTURE path, NOT the -// Design-View path; deliberately NO Save-As prompt.) +// UNSAVED-PROJECT GUARDRAIL: on an unsaved/no-active project saveToActiveProject() +// no-ops; we still CLOSE the block, but with an empty label + zero flag so REAPER +// discards the point instead of recording a no-effect undo entry. Quiet persist by +// design (mirrors capture, not Design-View) — deliberately no Save-As prompt. void persistBankOp(ReaSamplerSession& session, const char* label, bool bumpGeneration) { Undo_BeginBlock2(nullptr); - // S9: bump the bank-generation counter INSIDE the block, before the persist, so the - // fresh generation rides the same ext-state write (saveToActiveProject() stamps - // bankGeneration()). Bumped only for content-changing verbs (the caller decides); a - // pure-organizational verb passes false and leaves the counter be, so a - // rename/activate does not needlessly refresh live instances. if (bumpGeneration) session.bumpBankGeneration(); const bool persisted = session.saveToActiveProject(); if (persisted) @@ -109,7 +87,6 @@ bool bankOpDelete(ReaSamplerSession& session, const std::string& bankId, bool bankOpEvacuate(ReaSamplerSession& session, const std::string& bankId) { if (!session.book().evacuate(bankId)) return false; // pool is a destination, not a source - // S9: evacuate moves members between banks (bank membership changes) -> bump. persistBankOp(session, "ReaSampler: evacuate bank", /*bumpGeneration=*/true); return true; } @@ -120,14 +97,10 @@ bool bankOpActivate(ReaSamplerSession& session, const std::string& bankId) { return true; } -// NO-OP GUARDRAIL — VERB-AWARE (a collapse means different things per verb): -// * MOVE collapse: the source entry WAS removed (bank_book moveSample removes -// unconditionally before the dest add collapses on hash), so the index DID -// mutate — it counts toward opening an undo point. -// * COPY collapse: the source is left intact AND the dest already held the hash, -// so NOTHING changed — a true index no-op. It must NOT open an undo point. -// Hence: copy counts only real gains; move counts gains OR collapses. Ids pass -// straight to the model op — no BankModel& cached across the loop's mutations. +// NO-OP GUARDRAIL, verb-aware: a MOVE collapse still removed the source entry (the +// index DID mutate), but a COPY collapse left the source intact AND the dest already +// held the hash (a true no-op) — so copy counts only real gains, move counts gains +// OR collapses. bool bankOpTransfer(ReaSamplerSession& session, const std::vector& sampleIds, const std::string& srcBankId, const std::string& destBankId, @@ -151,18 +124,14 @@ bool bankOpTransfer(ReaSamplerSession& session, } const bool mutated = copy ? (ok > 0) : (ok > 0 || collapsed > 0); if (!mutated) return false; // nothing changed — no persist, no undo point - // S9: a move/copy changes bank membership (a sample arrives in / leaves a bank an - // instance may reference) -> bump so assigned instances refresh hands-free. persistBankOp(session, copy ? "ReaSampler: copy sample(s)" : "ReaSampler: move sample(s)", /*bumpGeneration=*/true); return true; } -// Index-only, this-bank scope (fork R-A: the sole surfaced verb; RemoveScope::AllBanks -// stays latent in the model). Non-destructive to the file: a last-reference remove -// leaves the file on disk, orphaned until Phase R prune — remove NEVER deletes bytes -// (the manifest is untouched). Silent: recoverability is the batched undo (R-B). +// Index-only, this-bank scope; non-destructive to the file (a last-reference remove +// leaves the file orphaned until prune). Silent: recoverability is the batched undo. bool bankOpRemove(ReaSamplerSession& session, const std::vector& sampleIds, const std::string& srcBankId) { @@ -174,8 +143,6 @@ bool bankOpRemove(ReaSamplerSession& session, RemoveResult::Removed) ++removed; if (removed == 0) return false; // every id already absent — no undo point - // S9: a remove drops a sample from a bank (an instance referencing it must refresh — - // it will resolve to silence, per the stale-id policy) -> bump. persistBankOp(session, "ReaSampler: remove sample(s)", /*bumpGeneration=*/true); return true; } diff --git a/src/shell/bank_ops/bank_ops.h b/src/shell/bank_ops/bank_ops.h index 659efa0..a6eaeaa 100644 --- a/src/shell/bank_ops/bank_ops.h +++ b/src/shell/bank_ops/bank_ops.h @@ -1,22 +1,14 @@ #pragma once -// bank_ops — the promptless bank-verb seam (Q-W6 lift of the Q-W4 single-owner -// verbs out of shell/panel/panel_bank_ops into a NON-UI home). Each verb is a model -// op on the given session's BankBook + persistBankOp (undo-batched ext-state -// persist) — NO prompts, NO message boxes, NO panel-state nudges, NO panel-global -// reads. The two UX surfaces consume these as thin skins: +// bank_ops — the promptless bank-verb seam. Each verb is a model op on the given +// session's BankBook + persistBankOp (undo-batched ext-state persist) — NO prompts, +// NO message boxes, NO panel-state nudges. Two UX surfaces consume these as thin +// skins: shell/panel/panel_bank_ops (menu prompts/confirms/repaints) and +// shell/actions/bank_actions (bindable family, text prompts/console feedback). // -// * shell/panel/panel_bank_ops — the panel's menu handlers (prompts / confirms / -// repaints), passing the panel's live session. -// * shell/actions/bank_actions — the bindable family (text prompts / console -// feedback), passing its registered session. -// -// The session arrives BY REFERENCE: there is exactly one session pointer question -// per call site (the caller's), so a missing session can never be half-reported as -// a model rejection from in here (the Q-W4 review's fail-safe-collapse concern). -// Every verb returns whether the model accepted the mutation — a rejected op -// persists nothing and opens no undo point. -// -// REAPER-facing (persist + undo blocks + GUID minting) but SDK-free in this header. +// The session arrives BY REFERENCE, so a missing session can never be +// half-reported as a model rejection from in here. Every verb returns whether the +// model accepted the mutation — a rejected op persists nothing and opens no undo +// point. REAPER-facing (persist + undo blocks + GUID minting) but SDK-free header. #include #include @@ -25,58 +17,47 @@ namespace reasampler { class ReaSamplerSession; -// Mints a stable GUID bank id, creates `name` in the book. Returns the new bank id, -// or "" when the model rejects the name (duplicate, trimmed + case-insensitive). -// Create is purely organizational — no generation bump. +// Returns "" when the model rejects the name (duplicate, trimmed + case-insensitive). +// Purely organizational — no generation bump. std::string bankOpCreate(ReaSamplerSession& session, const std::string& name); -// Renames `bankId`. False when the model rejects (pool un-renamable / name in use). +// False when the model rejects (pool un-renamable / name in use). bool bankOpRename(ReaSamplerSession& session, const std::string& bankId, const std::string& newName); -// Deletes `bankId`. False when the model rejects (pool un-deletable). The caller -// passes `bumpGeneration` from the member count it read BEFORE any evacuate/delete -// (an evacuate-then-delete flow must still bump on the ORIGINAL membership). +// False when the model rejects (pool un-deletable). `bumpGeneration` should be the +// member count read BEFORE any evacuate/delete (an evacuate-then-delete flow must +// still bump on the ORIGINAL membership). bool bankOpDelete(ReaSamplerSession& session, const std::string& bankId, bool bumpGeneration); -// Evacuates `bankId`'s members to the pool. False when the model rejects (the pool -// itself). Bumps the generation (membership changed). +// False when the model rejects (the pool itself). Bumps the generation. bool bankOpEvacuate(ReaSamplerSession& session, const std::string& bankId); -// Activates `bankId` as the capture target. False on an unknown id. No bump. +// False on an unknown id. No bump. bool bankOpActivate(ReaSamplerSession& session, const std::string& bankId); -// Moves (copy=false) or copies (copy=true) `sampleIds` from `srcBankId` to -// `destBankId` (index-only; files never relocate). Returns whether the index -// actually mutated — the verb-aware no-op guardrail: a COPY collapse changes -// nothing (no undo point); a MOVE collapse did remove the source entry (counts). -// Persists ONE undo point ("move/copy sample(s)") only when mutated. +// Index-only; files never relocate. Returns whether the index actually mutated — a +// COPY collapse changes nothing (no undo point), a MOVE collapse did remove the +// source entry (counts). Persists one undo point only when mutated. bool bankOpTransfer(ReaSamplerSession& session, const std::vector& sampleIds, const std::string& srcBankId, const std::string& destBankId, bool copy); -// Removes `sampleIds` from `srcBankId` (index-only, this-bank scope; never deletes -// bytes). Returns whether anything was removed; persists one undo point when so. +// Index-only, this-bank scope; never deletes bytes. Persists one undo point when +// anything was removed. bool bankOpRemove(ReaSamplerSession& session, const std::vector& sampleIds, const std::string& srcBankId); -// Persists a completed bank-index verb as a single REAPER undo point (R-B). -// Wraps the session persist (SetProjExtState) in a Begin/End block with -// UNDO_STATE_MISCCFG so the bank op is one Ctrl-Z. On an unsaved / no-active project -// the persist no-ops and the block is closed with an empty label + zero flag (REAPER -// discards it). Callers must invoke this ONLY after a successful/effective mutation — -// rejected ops (duplicate name, un-deletable pool, etc.) must return before reaching -// here so no empty undo point is ever opened for a no-op. +// Wraps the session persist in a Begin/End undo block (UNDO_STATE_MISCCFG) so the +// bank op is one Ctrl-Z; on an unsaved/no-active project the block closes empty +// (REAPER discards it). Call ONLY after an effective mutation. // -// S9 bank-generation bump: pass `bumpGeneration = true` for a verb that changes what a -// live instance would PLAY — move / copy / remove / evacuate / delete-with-members. Leave -// it false (the default) for a PURELY ORGANIZATIONAL verb — create / rename / activate / -// reorder. The bump (when requested) happens INSIDE the block, BEFORE the persist, so -// the stamped counter rides the same ext-state write and undo captures the pre/post -// generation with the rest of the blob. +// `bumpGeneration = true` for a verb that changes what a live instance would PLAY; +// leave false for a purely organizational verb. The bump happens INSIDE the block, +// before the persist, so the stamped counter rides the same ext-state write. void persistBankOp(ReaSamplerSession& session, const char* label, bool bumpGeneration = false); diff --git a/src/shell/capture/capture.cpp b/src/shell/capture/capture.cpp index b68fd8a..ec6ed70 100644 --- a/src/shell/capture/capture.cpp +++ b/src/shell/capture/capture.cpp @@ -1,38 +1,22 @@ -// capture.cpp — REAPER-facing offline-render backend (OfflineRenderBackend) plus -// the shared backend helpers (makeUniqueTag / stampCaptureSample — Q-W3 riders). +// REAPER-facing offline-render backend (OfflineRenderBackend) plus the shared +// backend helpers (makeUniqueTag / stampCaptureSample). // -// 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; here they are extern (CLAUDE.md §contract). +// Includes reaper_plugin_functions.h WITHOUT REAPERAPI_IMPLEMENT — main.cpp is +// the one TU that defines the API pointers; here they are extern. // -// Renders a CaptureRequest's source over its requested range. The full three-scope -// capture family (item / track / master, each over a razor-else-time range) is -// driven here — all wet-only with optional tail. FX scope is enforced by the -// caller (via FX-bypass-around-render / FxBypassGuard) before invoking capture; -// this backend is source-agnostic and does not itself read the DAW selection. -// Drives the RENDER_* project settings via GetSetProjectInfo / _String -// (the source-selection bits come from render_settings.cpp, the pure mapping), -// snapshots and restores every setting it changes (non-destructive), triggers a -// render, then populates a Sample. It NEVER inserts into the arrange -// (load-bearing principle) — RENDER_ADDTOPROJ&1 is cleared on every path. +// Drives the RENDER_* project settings via GetSetProjectInfo/_String (source- +// selection bits come from the pure render_settings mapping), snapshots and +// restores every setting it changes, triggers a render, then populates a Sample. +// Source-agnostic: never reads the DAW selection itself, only the CaptureRequest +// the caller resolved. RENDER_ADDTOPROJ&1 is cleared on every path — never +// inserts into the arrange. // -// The backend is SOURCE-AGNOSTIC: it does NOT read the DAW selection. The action -// layer (main.cpp) resolves each source mode to a concrete time range (+ track -// GUIDs for track captures) and hands it in via the CaptureRequest. This keeps -// the render-driving here and the selection-reading testable/visible up in the -// actions layer. -// -// RENDER PROGRESS WINDOW (Item 2 finding — not suppressible via stock API): -// Triggering kActionRenderUsingMostRecentSettings (42230) causes REAPER to show -// its offline-render progress dialog (progress bar + waveform view) for the -// duration of the render. The RENDER_SETTINGS bits documented in -// reaper_plugin_functions.h (line ~3041) contain no "no-dialog", "headless", or -// "suppress-progress-window" flag. No GetSetProjectInfo desc documents such a -// flag either. There is no stock, header-verifiable mechanism to prevent REAPER -// from showing this UI for an offline file render triggered via Main_OnCommand. -// This is inherent to REAPER's offline render path. The dialog-free alternative -// is the realtime-record backend (M8), which captures the master bus output to a -// temp track during playback and never invokes the offline render pipeline. +// RENDER PROGRESS WINDOW: triggering kActionRenderUsingMostRecentSettings (42230) +// shows REAPER's offline-render progress dialog for the render's duration; no +// RENDER_SETTINGS bit or GetSetProjectInfo desc suppresses it — inherent to +// REAPER's offline render path. The dialog-free alternative is the realtime- +// record backend, which captures the master bus to a temp track during playback +// and never invokes the offline render pipeline. #include "shell/capture/capture.h" @@ -64,72 +48,50 @@ namespace reasampler::capture { namespace { -// --- Render command / setting constants ------------------------------------- -// -// DAW-ONLY ASSUMPTION (open question, CONTEXT.md §Open questions): the no-dialog -// render is triggered by the built-in action "File: Render project, using the -// most recent render settings" — command id 42230. This is a stock REAPER main -// action id, NOT part of reaper_plugin_functions.h, so it CANNOT be verified -// against the SDK header; it must be confirmed in a running REAPER. It renders -// headlessly (no dialog) using whatever RENDER_* settings are currently on the -// project — which is exactly why we set them all explicitly first. +// The no-dialog render is the built-in action "File: Render project, using the +// most recent render settings" — command id 42230. Stock main action id, not in +// reaper_plugin_functions.h, confirmed against a running REAPER. Renders +// headlessly using whatever RENDER_* settings are currently on the project — +// why we set them all explicitly first. constexpr int kActionRenderUsingMostRecentSettings = 42230; -// RENDER_BOUNDSFLAG value 0 = custom time bounds (we set STARTPOS/ENDPOS -// ourselves for exact, unrounded bounds). Verified: SDK header line ~3042. +// RENDER_BOUNDSFLAG 0 = custom time bounds (we set STARTPOS/ENDPOS ourselves +// for exact, unrounded bounds). SDK header ~3042. constexpr double kBoundsCustom = 0.0; -// RENDER_TAILFLAG / RENDER_TAILMS / RENDER_NORMALIZE / RENDER_TRIMEND for the tail -// are driven from the pure tailRenderSettingsFor mapping (render_settings.h), -// unit-tested outside the DAW. See the tail-driving block in capture() below. +// RENDER_TAILFLAG/TAILMS/NORMALIZE/TRIMEND are driven from the pure +// tailRenderSettingsFor mapping (render_settings.h) in the tail-driving block below. -// RENDER_DITHER disable-all: &16 = disable all dither/noise-shaping. -// Verified: SDK header line ~3050: "&16=disable all". -// Float-32 output does not need dither, but if the user's project has dither -// enabled the render would obey it, breaking bit-identical repeats. Force off. +// RENDER_DITHER &16 = disable all dither/noise-shaping (SDK header ~3050). +// Float32 doesn't need dither, but an enabled project dither setting would +// otherwise apply and break bit-identical repeats. Force off. constexpr double kDitherDisableAll = 16.0; -// --- WAV render sink configuration ------------------------------------------ +// 32-bit IEEE float: lossless, needs no dither, so identical inputs render +// bit-identically and a dry capture nulls exactly against its source. 16/24-bit +// int paths need dither for correctness, which is nondeterministic. // -// FORMAT CHOICE (CONTEXT.md open question — surfaced for Daniel to confirm): -// 32-bit IEEE float. Rationale: float is lossless and needs NO dither, so -// identical inputs render bit-identically (enables the M10 null test) and a dry -// capture nulls exactly against its source. 16/24-bit int paths require dither -// for correctness, which is nondeterministic — unacceptable for a precision tool. +// GetSetProjectInfo_String("RENDER_FORMAT", ...) takes the BASE64-ENCODED sink +// config, not raw bytes (SDK header ~3114) — raw bytes are silently rejected and +// REAPER falls back to its project default format. // -// API FACT (SDK header line ~3114): GetSetProjectInfo_String("RENDER_FORMAT", ...) -// uses the BASE64-ENCODED string form of the sink config — NOT raw binary bytes. -// Writing raw bytes causes REAPER to silently reject the value and fall back to -// the project's default render format (typically 16-bit/44.1 kHz). This was the -// confirmed root cause of the M3 offline-capture regression. -// -// GROUND TRUTH: base64 string captured from a live REAPER configured to -// WAV / 32-bit float. Decodes to 7 bytes: 65 76 61 77 20 00 00 -// = "evaw" (WAV fourcc, little-endian) + 0x20 (=32, the float bit-depth field) -// + 0x00 0x00 (flags: little-endian, no BWF/loop metadata). +// Ground truth captured from a live REAPER set to WAV/32-bit float. Decodes to +// 7 bytes: "evaw" (WAV fourcc, LE) + 0x20 (float bit-depth) + 0x00 0x00 (flags). constexpr const char* kRenderFormatWavFloat32 = "ZXZhdyAAAA=="; -// Int16 / Int24 blob strings are NOT implemented in M3 — their byte encoding -// was not captured from a live REAPER and must not be guessed. If M7+ adds -// them, capture the ground-truth base64 from a running REAPER first. -// -// Returns nullptr for unsupported depths. +// Int16/Int24 blobs aren't implemented — no live-captured ground truth exists; +// do not guess the encoding. Returns nullptr for unsupported depths. const char* wavSinkConfigBase64(WavBitDepth depth) { switch (depth) { case WavBitDepth::Float32: return kRenderFormatWavFloat32; - case WavBitDepth::Int16: return nullptr; // M7+: capture ground-truth blob first - case WavBitDepth::Int24: return nullptr; // M7+: capture ground-truth blob first + case WavBitDepth::Int16: return nullptr; // capture ground-truth blob first + case WavBitDepth::Int24: return nullptr; // capture ground-truth blob first } return nullptr; } -// --- RENDER_* snapshot / restore -------------------------------------------- -// -// The RENDER_* settings are project-GLOBAL: clobbering them would destroy the -// user's render configuration. We snapshot every value we are about to change, -// then restore all of them in the reverse order on the way out (non-destructive -// invariant). Modeled as a small RAII guard so early returns cannot leak a -// half-restored state. +// RENDER_* settings are project-GLOBAL; snapshot every value we touch and +// restore on the way out via RAII so early returns can't leak a half-restored state. struct RenderSettingsSnapshot { ReaProject* proj = nullptr; @@ -191,8 +153,6 @@ void snapshotRenderSettings(RenderSettingsSnapshot& s, ReaProject* proj) { void restoreRenderSettings(const RenderSettingsSnapshot& s) { if (!s.captured) return; - // Restore strings first, then numerics — order is not load-bearing since the - // fields are independent, but we mirror snapshot order for readability. setProjString(s.proj, "RENDER_FILE", s.renderFile); setProjString(s.proj, "RENDER_PATTERN", s.renderPattern); setProjString(s.proj, "RENDER_FORMAT", s.renderFormat); @@ -221,30 +181,16 @@ struct ScopedRenderSettings { ScopedRenderSettings& operator=(const ScopedRenderSettings&) = delete; }; -// Whole-file reads go through the shared core/util readFileBytes (Q-W1, T2-03): -// empty on any I/O failure (the caller then leaves contentHash empty — the safe, -// confirm-eliciting direction for an unreadable file). - } // namespace -// --- Shared backend helpers (Q-W3 riders — see capture.h) -------------------- - std::string makeUniqueTag(const std::string& prefix) { - // Timestamp + PER-SESSION MONOTONIC counter (T1-11 fix). The timestamp alone - // had one-second resolution: two captures of the same baseName within the same - // wall-clock second derived the same file stem, so the second render silently - // overwrote the first file and minted two Samples with colliding ids — - // reachable in practice via batch capture. The counter (shared across both - // backends — this is the one definition both call) makes every tag of a - // session distinct regardless of timing. NOTE: the tag varies the file NAME, - // not the audio bytes — bit-identical-repeat is about identical *content* for - // identical requests; two deliberate captures naturally live in two files. - // RESIDUAL (Q-W3 review follow-up): the counter is per-process, starting over - // at 0 on every REAPER launch/extension reload, so two separate REAPER - // instances (or a reload mid-session) can still mint the same timestamp+counter - // pair in the same wall-clock second — a same-second cross-process collision - // remains theoretically possible. Scoped to per-session deliberately: this fix - // targets the reachable-in-practice single-process batch-capture case above. + // Timestamp + per-session monotonic counter: the timestamp alone has one-second + // resolution, so two captures of the same baseName within a second (batch + // capture) collided on file stem and Sample id. This varies the file NAME, not + // the audio bytes — bit-identical-repeat is about identical content per request. + // Residual: the counter resets per-process, so a same-second collision across + // two REAPER instances (or a mid-session reload) remains theoretically possible; + // scoped deliberately to the reachable single-process case. static std::atomic counter{0}; const std::time_t now = std::time(nullptr); return prefix + std::to_string(static_cast(now)) + "-" + @@ -259,26 +205,19 @@ void stampCaptureSample(Sample& s, const CaptureRequest& req, s.trackGuids = req.trackGuids; s.channelCount = req.channelCount; - // Resolved sample rate: the request's pinned rate, else PROJECT_SRATE read - // from the caller's project handle. PROJECT_SRATE can read 0 on a project that - // never explicitly pinned a rate — the value stays 0 (the Sample zero-value) - // rather than a bogus literal (the honest "unknown" both backends shared). + // PROJECT_SRATE can read 0 on a project that never pinned a rate — stays 0 + // (honest "unknown") rather than a bogus literal. s.sampleRate = (req.sampleRate > 0) ? req.sampleRate : static_cast(GetSetProjectInfo(rateProj, "PROJECT_SRATE", 0.0, false)); - s.captureTempo = Master_GetTempo(); // BPM at capture time (verified ~4651) + s.captureTempo = Master_GetTempo(); // BPM at capture time - // Time signature at the capture's START time (L7 F1 stamp). TimeMap_GetTimeSigAtTime - // (verified reaper_plugin_functions.h:7130 — void(ReaProject*, double time, - // int* numOut, int* denomOut, double* tempoOut)) reads the meter effective at - // that project time, so a sample captured under 3/4 keeps a 3/4 read-out even - // if the project later switches to 4/4. `timeSigProj` is the CALLER's project - // pin — offline passes nullptr (the active project); realtime pins the record's - // own project (the T2-09 divergence, kept caller-visible as this argument). - // tempoOut is ignored — captureTempo already carries the master tempo. Leaves - // 0/0 (unstamped) if the API is somehow unavailable; the formatter renders a - // blank musical read-out. + // Time signature effective at the capture's START time, so a sample captured + // under 3/4 keeps a 3/4 read-out even if the project later switches to 4/4. + // `timeSigProj` is the caller's project pin — offline passes nullptr (active + // project); realtime pins the record's own project. tempoOut is ignored — + // captureTempo already carries it. { int tsNum = 0, tsDenom = 0; double tsTempo = 0.0; @@ -287,14 +226,10 @@ void stampCaptureSample(Sample& s, const CaptureRequest& req, s.captureTimeSigDenom = tsDenom; } - // Content hash: WAV-aware FNV-1a over the finished file's fmt+data chunks so - // hashReferencedElsewhere can identify copies in other banks and suppress the - // last-reference confirm when another bank still holds the same file. Using - // hashWavContent (not the raw hashBytes) skips render-varying metadata chunks - // (bext origination timestamp, iXML, LIST/INFO, etc.) so two renders/records of - // identical audio collapse to the same hash. Best-effort: an unreadable file - // leaves contentHash empty — the safe, confirm-eliciting direction (bank_model - // treats "" as non-participating in dedup). + // hashWavContent (not raw hashBytes) skips render-varying metadata chunks + // (bext timestamp, iXML, LIST/INFO) so identical audio from two renders/records + // collapses to the same hash, letting dedup find copies across banks. + // Unreadable file leaves contentHash empty (bank_model treats "" as non-dedup). { const std::vector fileBytes = util::readFileBytes(absolutePath); if (!fileBytes.empty()) { @@ -308,16 +243,14 @@ void stampCaptureSample(Sample& s, const CaptureRequest& req, CaptureResult OfflineRenderBackend::capture(const CaptureRequest& request) { CaptureResult result; - // Resolve the RENDER_SETTINGS source/processing bits for this mode + wet/dry - // (pure mapping, unit-tested in render_settings). An unsupported mode (only - // SourceMode::Realtime — that is the M8 realtime backend) is refused here so - // the offline path never silently renders the wrong thing. + // SourceMode::Realtime is refused here — that's the realtime backend's job — + // so the offline path never silently renders the wrong thing. const RenderSettingsChoice choice = renderSettingsFor(request.sourceMode, request.wetDry); if (!choice.supported) { result.status = CaptureStatus::UnsupportedMode; result.message = "OfflineRenderBackend does not render this source mode " - "(realtime capture is the M8 backend)."; + "(realtime capture is the realtime backend)."; return result; } @@ -328,8 +261,7 @@ CaptureResult OfflineRenderBackend::capture(const CaptureRequest& request) { return result; } - // Current project (idx -1 == the active project tab). Verified: SDK header - // line ~1264, EnumProjects(int idx, char*, int). + // idx -1 == the active project tab. ReaProject* proj = EnumProjects(-1, nullptr, 0); if (!proj) { result.status = CaptureStatus::NoProject; @@ -337,131 +269,81 @@ CaptureResult OfflineRenderBackend::capture(const CaptureRequest& request) { return result; } - // Resolve the project directory from the .rpp file path. + // Unsaved-project detection via EnumProjects(-1, buf, bufsz): the .rpp path + // out-param is empty for a project that has never been saved — a reliable + // unsaved sentinel. NOT GetProjectPathEx: that returns the recording path, not + // the .rpp location, and is never empty even when unsaved (the original bug — + // captures landed in REAPER's default media location instead of by the .rpp). // - // Unsaved-project detection: we use EnumProjects(-1, buf, bufsz) to read - // the project's .rpp filename. Per SDK header line ~1262: - // EnumProjects(int idx, char* projfnOutOptional, int sz) - // "idx=-1 for current project, projfn can be NULL if not interested in filename." - // The out-parameter is the full path to the .rpp file, and is EMPTY for a - // project that has never been saved — making it a reliable unsaved sentinel. - // - // WHY NOT GetProjectPathEx: that function returns the project *recording path* - // (SDK header line ~2548: "Get the project recording path."), NOT the .rpp - // location. For an unsaved project it returns REAPER's default media/recording - // directory — never empty — so it cannot detect the unsaved state. Using it - // caused the original bug: the guard never fired, and captures landed in - // REAPER's default media location rather than alongside the .rpp. - // - // WHY NOT GetProjectPathEx for the saved-project dir: even for a saved project, - // GetProjectPathEx returns the recording path (which may be a media subfolder), - // not the .rpp parent directory. We need the .rpp parent so reasampler_bank/ - // sits alongside the .rpp and travels with the project. - // - // FLOW: - // 1. Read .rpp path via EnumProjects(-1, buf, bufsz). - // 2. If non-empty (saved) -> derive project dir as parent of the .rpp. - // 3. If empty (unsaved) -> Main_SaveProject(proj, true) prompts Save-As. - // Re-read. If now non-empty -> proceed. If still empty (user cancelled) -> - // refuse CaptureStatus::NoProject, write nothing. - // - // DAW-ONLY ASSUMPTION: Main_SaveProject(proj, true) opens a Save/Save-As - // dialog and blocks until the user dismisses it. "true" = forceSaveAsIn. - // Verified SDK header line ~4599: - // void Main_SaveProject(ReaProject* proj, bool forceSaveAsInOptional) - // The blocking behaviour and dialog appearance can only be confirmed in a - // running REAPER. + // Flow: read .rpp path; if empty, Main_SaveProject(proj, true) prompts + // Save-As and blocks until dismissed; re-read; if still empty (cancelled), + // refuse with NoProject and write nothing. auto readRppPath = [&]() -> std::string { std::vector buf(4096, '\0'); - // EnumProjects(-1, ...) returns the active project and writes the .rpp - // path into buf. We already have the ReaProject* from the earlier call - // (nullptr-checked above), but calling EnumProjects again is the only - // stock, header-documented way to read the .rpp filename. EnumProjects(-1, buf.data(), static_cast(buf.size())); return std::string(buf.data()); }; std::string rppPath = readRppPath(); if (rppPath.empty()) { - // Project is unsaved. Prompt the user to choose a save location. Main_SaveProject(proj, true); - // Re-read: non-empty if the user confirmed, still empty if cancelled. rppPath = readRppPath(); } if (rppPath.empty()) { - // User cancelled the save dialog — refuse, write nothing. result.status = CaptureStatus::NoProject; result.message = "Project must be saved before capture — nothing captured."; return result; } - // Derive the project directory as the parent folder of the .rpp file. - // std::filesystem::path handles both forward- and back-slash paths; .parent_path() - // gives the containing directory. Convert to forward-slash string so the rest - // of the capture pipeline (deriveBankPaths, RENDER_FILE) sees a clean path. + // Project dir = parent of the .rpp; forward-slash-normalized so the rest of + // the capture pipeline (deriveBankPaths, RENDER_FILE) sees a clean path. const std::string projectDir = [&]() -> std::string { namespace fs = std::filesystem; std::string dir = fs::path(rppPath).parent_path().string(); - // normalizeSlashes is in capture_paths (pure); replicate the transform - // inline here to avoid a cross-module dependency for a one-liner. for (char& c : dir) { if (c == '\\') c = '/'; } - // Strip a single trailing slash (defensive; parent_path usually omits it). if (dir.size() > 1 && dir.back() == '/') dir.pop_back(); return dir; }(); - // Compute the unique tag ONCE so the file stem and Sample.id carry the same - // tag. Calling makeUniqueTag() twice would yield different values (the counter - // advances per call — bug: id and filename diverge). + // Compute the tag ONCE — calling makeUniqueTag() twice would let the file + // stem and Sample.id diverge (the counter advances per call). const std::string uniqueTag = makeUniqueTag(""); const BankPaths paths = deriveBankPaths(projectDir, request.baseName, uniqueTag); - // Snapshot + auto-restore ALL render settings we are about to touch. ScopedRenderSettings guard(proj); - // --- Drive the render settings (exact, deterministic) ------------------- // Custom time bounds so the rendered length equals the requested range with // NO rounding and NO added silence (unless a tail was explicitly requested). GetSetProjectInfo(proj, "RENDER_BOUNDSFLAG", kBoundsCustom, true); GetSetProjectInfo(proj, "RENDER_STARTPOS", request.startSeconds, true); GetSetProjectInfo(proj, "RENDER_ENDPOS", request.endSeconds, true); - // Tail: TAILFLAG / TAILMS / NORMALIZE / TRIMEND all come from the pure mapping - // (render_settings.h, unit-tested). None -> exact bounds + disable-all normalize - // (byte-identical to the pre-tail path); Auto -> 8 s tail + surgical trim-end - // normalize + -72 dB TRIMEND; Manual -> clamped fixed tail + disable-all, no trim. - // RENDER_NORMALIZE is driven HERE from the mapping (not the determinism block - // below) so the Auto surgical value is not clobbered — the snapshot guard restores - // the user's original RENDER_NORMALIZE / RENDER_TRIMEND on every exit path. + // TAILFLAG/TAILMS/NORMALIZE/TRIMEND from the pure mapping: None -> exact + // bounds + disable-all normalize; Auto -> 8s tail + surgical trim-end + // normalize + -72 dB TRIMEND; Manual -> clamped fixed tail + disable-all, no + // trim. NORMALIZE is driven here (not the determinism block below) so the + // Auto surgical value isn't clobbered. const TailRenderSettings tail = tailRenderSettingsFor(request.tailMode, request.tailMs); GetSetProjectInfo(proj, "RENDER_TAILFLAG", static_cast(tail.tailFlag), true); GetSetProjectInfo(proj, "RENDER_TAILMS", tail.tailMs, true); - // Source-selection bits for this mode, from the pure render_settings mapping - // (verified against SDK header ~3041). All M7 actions are wet-only: + // Source-selection bits for this mode (SDK header ~3041), all wet-only: // master mix = 0; tracks = &128; items = &32|single-file; razor = &4096|single-file. GetSetProjectInfo(proj, "RENDER_SETTINGS", static_cast(choice.settings), true); - // Resolve the effective sample rate. When the request carries 0 ("follow - // project"), read PROJECT_SRATE explicitly so RENDER_SRATE is set to the - // actual value — not left as 0 for REAPER to interpret. SDK header line ~3064: - // PROJECT_SRATE = sample rate (ignored unless PROJECT_SRATE_USE set); the - // value is still readable via GetSetProjectInfo even when _USE is clear. + // request 0 = "follow project"; PROJECT_SRATE is still readable via + // GetSetProjectInfo even when PROJECT_SRATE_USE is clear. const int effectiveSampleRate = (request.sampleRate > 0) ? request.sampleRate : static_cast(GetSetProjectInfo(proj, "PROJECT_SRATE", 0.0, false)); - // Pin RENDER_SRATE only when the resolved rate is known (> 0). PROJECT_SRATE - // can read 0 on a project that has never explicitly pinned a sample rate (e.g. - // brand-new projects before the user has visited the project settings). Forcing - // RENDER_SRATE = 0 would re-introduce the "0 as literal" trap we fixed by - // moving away from blind passthrough. When the rate is unknown, leave - // RENDER_SRATE unset so REAPER follows its own project-rate default — which is - // correct behaviour for that project — rather than pinning a bogus 0. + // Only pin RENDER_SRATE when known (>0) — a brand-new project can read 0 for + // PROJECT_SRATE, and forcing RENDER_SRATE=0 would be a bogus literal; leave + // it unset so REAPER follows its own project-rate default. if (effectiveSampleRate > 0) { GetSetProjectInfo(proj, "RENDER_SRATE", static_cast(effectiveSampleRate), true); @@ -469,100 +351,67 @@ CaptureResult OfflineRenderBackend::capture(const CaptureRequest& request) { GetSetProjectInfo(proj, "RENDER_CHANNELS", static_cast(request.channelCount), true); - // Load-bearing principle: do NOT add the rendered file to the project as an - // item. Clearing RENDER_ADDTOPROJ&1 keeps capture out of the arrange. + // Load-bearing: never add the rendered file to the project as an item. GetSetProjectInfo(proj, "RENDER_ADDTOPROJ", 0.0, true); - // Determinism: disable dither so identical inputs produce bit-identical files - // and a dry capture nulls to silence. RENDER_DITHER &16 = disable all dither/ - // noise-shaping (SDK header line ~3050). Snapshotted above; restored by the guard. GetSetProjectInfo(proj, "RENDER_DITHER", kDitherDisableAll, true); - // RENDER_NORMALIZE + RENDER_TRIMEND come from the tail mapping (above). None / - // Manual -> disable-all (byte-identical to the pre-tail path); Auto -> surgical - // trim-end (only &32768) + the -72 dB TRIMEND. A fixed-threshold trailing-silence - // trim scales/limits/fades nothing, so Auto stays deterministic and un-coloring - // (spec §surgical normalize). TRIMEND is only consulted when the trim bit is set, - // but we write it unconditionally (harmless when clear) so the value is explicit. + // None/Manual -> disable-all (byte-identical to pre-tail); Auto -> surgical + // trim-end (only &32768) + -72 dB TRIMEND — a fixed-threshold trailing-silence + // trim scales/limits/fades nothing, so Auto stays deterministic. TRIMEND is + // only consulted when the trim bit is set but written unconditionally for clarity. GetSetProjectInfo(proj, "RENDER_NORMALIZE", static_cast(tail.normalize), true); GetSetProjectInfo(proj, "RENDER_TRIMEND", tail.trimEnd, true); - // Output location: directory (RENDER_FILE) + file stem (RENDER_PATTERN). // RENDER_PATTERN with no wildcards is a literal stem; REAPER appends the - // format extension. Use paths.fileStem — capture_paths owns the .wav suffix - // knowledge; re-stripping here would duplicate that coupling. + // format extension. paths.fileStem already owns the .wav suffix knowledge. setProjString(proj, "RENDER_FILE", paths.absoluteDir); setProjString(proj, "RENDER_PATTERN", paths.fileStem); - // Pin the WAV format using the ground-truth base64 blob for the chosen depth. - // Int16/Int24 are not implemented (no live-captured blob) — fail explicitly - // rather than silently mis-render at the wrong bit depth. + // Int16/Int24 have no captured ground-truth blob — fail explicitly rather + // than silently mis-render at the wrong bit depth. const char* fmtBase64 = wavSinkConfigBase64(request.bitDepth); if (!fmtBase64) { result.status = CaptureStatus::UnsupportedFormat; result.message = "Requested bit depth has no verified RENDER_FORMAT blob " - "(M3 supports Float32 only; Int16/Int24 are M7+)."; + "(Float32 only; Int16/Int24 not yet supported)."; return result; - // guard's dtor restores every RENDER_* setting here. } setProjString(proj, "RENDER_FORMAT", fmtBase64); - // --- Trigger the render ------------------------------------------------- - // DAW-ONLY ASSUMPTION (see kActionRenderUsingMostRecentSettings): this runs - // the render synchronously on the current build. REAPER will show its - // offline-render progress window for the duration (see file-top comment — - // the progress UI is not suppressible via stock API). Main_OnCommand(kActionRenderUsingMostRecentSettings, 0); - // --- Verify the output file exists --------------------------------------- - // Main_OnCommand returns void, so a failed render is silent. Stat the - // expected output path; if the file does not exist the render failed. - // Note: std::filesystem is used only in this REAPER-facing .cpp — the pure - // libs (capture_paths, bank_model) remain filesystem-free. + // Main_OnCommand returns void, so a failed render is silent — stat the + // expected output path to detect it. const std::string expectedPath = paths.absoluteDir + "/" + paths.fileName; if (!std::filesystem::exists(expectedPath)) { result.status = CaptureStatus::RenderFailed; result.message = "Render produced no output file (expected: " + expectedPath + "). Check the REAPER console for errors."; return result; - // guard's dtor restores every RENDER_* setting here. } - // --- Populate the Sample ------------------------------------------------- - // We record the request's own bounds (exact) rather than re-measuring the - // file, so the Sample's range is precisely what was asked for. + // Record the request's own bounds (exact) rather than re-measuring the file. Sample s; - // Use the same uniqueTag that named the file — calling makeUniqueTag() again - // here would risk a different timestamp if a second boundary crosses between - // the two calls, making Sample.id inconsistent with the file name. + // Same uniqueTag that named the file — calling makeUniqueTag() again could + // yield a different value and desync Sample.id from the file name. s.id = "cap-" + uniqueTag + "-" + paths.fileName; s.displayName = request.baseName; s.relativePath = paths.relativePath; // project-relative (invariant) s.sourceMode = request.sourceMode; s.sourceRange.startSeconds = request.startSeconds; s.sourceRange.endSeconds = request.endSeconds; - // DEFERRED (M6/M7): startPpq, endPpq, and lengthBeats are left at 0. - // PPQ mapping via TimeMap2_timeToBeats is a musical-placement concern for the - // insert milestone; the model refuses to re-derive one bound from the other. - // Seconds are the authoritative source for the render. Do NOT add DAW- - // unverifiable PPQ resolution here — it requires a live REAPER to validate. + // startPpq/endPpq/lengthBeats left at 0 — PPQ mapping is a placement-time + // concern; seconds are the authoritative source for the render and we don't + // re-derive one bound from the other. s.wetDry = request.wetDry; s.lengthSeconds = request.endSeconds - request.startSeconds; - s.tier = model::Tier::Scratch; // captures land in scratch by default - // The SHARED finished-capture stamp (T2-09 dedupe): trackGuids + channelCount - // (request echo), resolved sampleRate (request rate else PROJECT_SRATE(proj) — - // 0 stays 0 when the project never pinned a rate; we did not force RENDER_SRATE - // either, so the render ran at REAPER's default), captureTempo, the capture- - // start time signature (timeSigProj = nullptr => the active project — matching - // the Master_GetTempo read, which is also active-project), the WAV-aware - // contentHash of the rendered file, and createdTimestamp. + s.tier = model::Tier::Scratch; stampCaptureSample(s, request, proj, /*timeSigProj=*/nullptr, expectedPath); - // Phase S seam fields (rootNote / loop) left empty (D-B). An offline render of a - // master mix / track / time-selection is not a single played note, so no root - // note is derivable here — we do NOT guess one. Loop points are set later by an - // explicit user action, not at capture. Leaving them empty is the honest default; - // the instrument (Phase S) treats an absent root note as "not a pitched sample". + // rootNote/loop left empty — a master/track/time-selection render isn't a + // single played note, so no root note is derivable; loop points are set + // later by an explicit user action. result.status = CaptureStatus::Ok; result.sample = s; @@ -571,7 +420,6 @@ CaptureResult OfflineRenderBackend::capture(const CaptureRequest& request) { std::to_string(request.endSeconds) + "s] -> " + paths.relativePath; return result; - // guard's dtor restores every RENDER_* setting here. } } // namespace reasampler::capture diff --git a/src/shell/capture/capture.h b/src/shell/capture/capture.h index e02aa56..62a511d 100644 --- a/src/shell/capture/capture.h +++ b/src/shell/capture/capture.h @@ -1,36 +1,18 @@ #pragma once -// capture — the REAPER-facing capture shell (CLAUDE.md §load-bearing split). +// The shared capture seam: CaptureRequest/CaptureResult (types both backends +// speak), OfflineRenderBackend, and the makeUniqueTag/stampCaptureSample helpers. +// Realtime's async begin/tick/abort surface lives in capture_realtime_shell.h. // -// This header declares the SHARED capture seam (Q-W6 split of the former fat -// header — the realtime backend's async begin/tick/abort surface now lives in -// capture_realtime_shell.h): -// * CaptureRequest / CaptureResult — everything a capture needs and yields, -// source-mode-agnostic; the types BOTH backends speak. -// * OfflineRenderBackend — the deterministic default; a plain CONCRETE class -// (the former ICaptureBackend interface was deleted in -// Q-W3, T4-26 — it had one deriver and zero polymorphic -// call sites; every construction site instantiates the -// concrete type). -// * makeUniqueTag / stampCaptureSample — the shared file-tag mint and the shared -// finished-capture metadata stamp both backends call -// (Q-W3 riders T1-11 / T2-09). -// -// It includes bank_model (pure) to hand back a populated Sample, but NO REAPER -// headers — the .cpp is the REAPER-facing translation unit. Keeping this header -// REAPER-free lets callers (the capture orchestration TUs) depend on the seam -// without dragging the SDK into every include site. +// REAPER-free on purpose (bank_model only) so callers can depend on the seam +// without dragging the SDK into every include site; the .cpp is the REAPER TU. #include #include #include "core/model/bank_model.h" -#include "core/capture/render_settings.h" // TailMode (pure) — the three-state tail contract +#include "core/capture/render_settings.h" // TailMode — the three-state tail contract -// MediaTrack / ReaProject are forward-declared (like track_guid.h) so this header -// stays REAPER-free while RealtimeRecordBackend::begin can take the resolved source -// MediaTrack* to tap and stampCaptureSample can take the project handles its reads -// pin. The pointers are opaque here — never dereferenced in a pure/header context; -// only the REAPER-facing capture TUs touch them. +// Forward-declared, never dereferenced here — only the REAPER-facing .cpp touches these. class MediaTrack; class ReaProject; @@ -38,71 +20,55 @@ namespace reasampler::capture { using model::Sample; -// Audio bit-depth for the rendered wav. 32-bit float is the M3 default — -// rationale lives in capture.cpp next to the sink-config bytes. +// 32-bit float is the default; rationale lives in capture.cpp next to the sink-config bytes. enum class WavBitDepth { Int16, Int24, Float32, }; -// One capture, independent of source mode. Populated by the caller (the action -// handler in M3; the action family in M7) and consumed by a backend. -// -// M3 fills only the fields the master-mix/time-selection path needs; the rest -// are declared now so M7/M8 do not reshape the struct (they are the seam). +// One capture, independent of source mode. struct CaptureRequest { SourceMode sourceMode = SourceMode::MasterMix; - // Sample-accurate render bounds in project seconds. For the M3 spike these - // come straight from the time selection (GetSet_LoopTimeRange) — NO rounding. + // Sample-accurate render bounds in project seconds — NO rounding. double startSeconds = 0.0; double endSeconds = 0.0; - // 1.0 = fully wet, 0.0 = fully dry. All three-scope capture actions set this to 1.0 (wet). - // The field is kept as the seam for future true-dry work (M10 null test): - // true pre-FX dry offline is NOT available via RENDER_SETTINGS — it requires - // FX-bypass-around-render or the M8 realtime pre-FX path, and will be - // designed alongside the M10 null test. Also recorded on the Sample. + // 1.0 = fully wet, 0.0 = fully dry. Every current capture action sets 1.0; + // true pre-FX dry isn't available via RENDER_SETTINGS (needs FX-bypass-around-render + // or the realtime pre-FX path) so this stays a seam for that future work. double wetDry = 1.0; - // Track GUID(s) the capture came from, when the source mode is track-scoped - // (SelectedTracks). Empty for master/items/razor. The action layer (M7) - // resolves the selection to canonical GUID strings and passes them here; the - // backend copies them onto the Sample (it does NOT itself read the selection — - // it stays source-agnostic, driven entirely by the request). + // Track GUID(s) when source mode is track-scoped (SelectedTracks); empty otherwise. + // The backend only copies these onto the Sample — it never reads selection itself. std::vector trackGuids; - // Render tail (docs/product/capture-tail.md §The three tail states). Default - // None: exact bounds, no added silence — the precision invariant, and the only - // mode valid for null-test / verify captures. `tailMs` is meaningful ONLY for - // TailMode::Manual (clamped to the 8 s cap by the pure mapping); Auto uses the - // 8 s cap + -72 dB trim internally, None ignores it. + // Render tail (docs/product/capture-tail.md §The three tail states). None = exact + // bounds, no added silence — the only mode valid for null-test/verify captures. + // tailMs applies only to Manual (clamped to 8s by the pure mapping); Auto uses + // the 8s cap + -72 dB trim internally, None ignores it. TailMode tailMode = TailMode::None; double tailMs = 0.0; - // Output format. 0 sampleRate => follow project rate (deterministic: the - // project rate is fixed for a given project). + // 0 sampleRate => follow project rate. int sampleRate = 0; int channelCount = 2; WavBitDepth bitDepth = WavBitDepth::Float32; - // Human base name for the file stem; sanitized by capture_paths. The unique - // tag (disambiguator) is supplied separately by the backend caller so the - // pure naming logic stays testable. + // Sanitized by capture_paths. uniqueTag (disambiguator) is supplied by the + // backend caller so the pure naming logic stays testable. std::string baseName = "capture"; - std::string uniqueTag; // e.g. a timestamp/counter; may be empty + std::string uniqueTag; }; -// Outcome of a capture attempt. `Ok` carries the populated Sample; every failure -// is an explicit code (never a thrown exception across the REAPER boundary) so -// the action handler can log a precise reason. +// Every failure is an explicit code, never a thrown exception across the REAPER boundary. enum class CaptureStatus { Ok, NoProject, // no active project to render / resolve a bank folder EmptyRange, // start >= end: nothing to render - UnsupportedMode, // backend does not implement this source mode (M3 scope) - UnsupportedFormat, // requested bit depth has no known REAPER blob (M3: Float32 only) + UnsupportedMode, // backend does not implement this source mode + UnsupportedFormat, // requested bit depth has no known REAPER blob (Float32 only) RenderFailed, // the render action ran but produced no output file TransportBusy, // realtime backend: transport already playing/recording — refused }; @@ -113,44 +79,33 @@ struct CaptureResult { std::string message; // human-readable detail for the console log }; -// Deterministic offline-render backend. Drives the full offline source family — -// master mix / time selection, selected tracks, selected items, razor area — all -// wet-only (render_settings.h) with optional tail. The source selection + range -// are resolved by the caller (the action layer) and handed in via the -// CaptureRequest; the backend drives RENDER_* and never reads the DAW selection -// itself. SourceMode::Realtime returns UnsupportedMode (that is the M8 backend). -// Non-destructive: restores every RENDER_* setting it touches on every path. -// A plain concrete class — the former ICaptureBackend interface was deleted -// (Q-W3, T4-26): it had one deriver, zero polymorphic call sites, and the async -// realtime backend deliberately never implemented it (see SEAM CHOICE below). +// Deterministic offline-render backend: master mix / time selection / selected +// tracks / selected items / razor area, all wet-only, optional tail. Source +// selection + range are resolved by the caller and handed in via CaptureRequest — +// the backend drives RENDER_* and never reads the DAW selection itself. +// SourceMode::Realtime returns UnsupportedMode. Non-destructive: restores every +// RENDER_* setting it touches on every path. Plain concrete class — see the +// no-shared-interface note in capture_realtime_shell.h before adding one back. class OfflineRenderBackend { public: CaptureResult capture(const CaptureRequest& request); }; -// --- Shared backend helpers (Q-W3 riders) ------------------------------------ - // Mints the filesystem-safe disambiguating tag for one capture's file stem + -// Sample id: "-" where is a PER-SESSION -// MONOTONIC counter (T1-11 fix). The wall-clock second alone had a collision -// window: two captures of the same baseName within one second derived the same -// stem, so the second render silently overwrote the first file (reachable via -// batch capture driving short renders back-to-back). The counter makes every tag -// of a session distinct regardless of timing. `prefix` is the backend's family -// marker ("" offline, "rt-" realtime). +// Sample id: "-", a per-session monotonic +// counter. Wall-clock seconds alone collide when batch capture drives short +// renders back-to-back, silently overwriting the first file. `prefix` is the +// backend's family marker ("" offline, "rt-" realtime). std::string makeUniqueTag(const std::string& prefix); -// Stamps the SHARED finished-capture metadata onto `s` (T2-09 dedupe — this stamp -// was copy-pasted per backend and had silently diverged): trackGuids + -// channelCount (echoed from the request), the resolved sampleRate (request rate, -// else PROJECT_SRATE read from `rateProj`; 0 stays 0 when unknown), captureTempo -// (Master_GetTempo), the capture-start time signature (TimeMap_GetTimeSigAtTime -// against `timeSigProj` — the offline path passes nullptr = active project, the -// realtime path pins the record's own project; the divergence stays caller-visible -// as this argument), the WAV-aware contentHash of the finished file at -// `absolutePath` (left empty when unreadable — the safe, confirm-eliciting -// direction), and createdTimestamp (now). The per-backend bits (id, paths, bounds, -// tier, realtime's recorded-length override) stay with each caller. +// Stamps the metadata shared by both backends onto `s`: trackGuids + channelCount +// (echoed from the request), resolved sampleRate (request rate, else PROJECT_SRATE +// from `rateProj`), captureTempo, the capture-start time signature +// (TimeMap_GetTimeSigAtTime against `timeSigProj` — offline passes nullptr for the +// active project, realtime pins the record's own project), the WAV-aware +// contentHash of `absolutePath` (left empty when unreadable), and createdTimestamp. +// Per-backend bits (id, paths, bounds, tier, realtime's length override) stay +// with each caller. void stampCaptureSample(Sample& s, const CaptureRequest& req, ReaProject* rateProj, ReaProject* timeSigProj, const std::string& absolutePath); diff --git a/src/shell/capture/capture_batch.cpp b/src/shell/capture/capture_batch.cpp index e22e4f9..b4ff45a 100644 --- a/src/shell/capture/capture_batch.cpp +++ b/src/shell/capture/capture_batch.cpp @@ -1,10 +1,9 @@ -// capture_batch.cpp — the M11 batch-capture family + the M10 re-capture-from-source -// action (Q-W3 hoist out of main.cpp; the code moved verbatim, the session threaded -// as a parameter). See the header. +// capture_batch.cpp — the batch-capture family + re-capture-from-source. See the +// header. // -// Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.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; here they are extern (CLAUDE.md §contract). +// pointers; here they are extern. #include "shell/capture/capture_batch.h" @@ -50,28 +49,21 @@ namespace reasampler::capture { -// --- M11: batch capture (per selected item / per razor area) ---------------- +// One action fires N captures — one sample per selected item (item scope) or per +// razor area (track scope, each area's own range). Each unit routes through +// captureAndIndexOne so every precision invariant holds; nothing lands in the +// arrange (load-bearing principle). // -// One action fires N captures — one bank sample per selected item (item scope) or per -// razor area (track scope, each area's own range). Each individual capture honors every -// precision invariant via captureAndIndexOne (exact bounds, non-destructive FX/fader/pan -// neutralize, relative paths, channel preservation) and M10 provenance stamping applies -// per capture where its detection rule matches. The load-bearing principle holds: each -// unit writes a file + a bank index entry ONLY; nothing lands in the arrange. -// -// Per-unit FILE NAMING: each unit's baseName carries its ordinal ("item-1", -// "item-2", ...) so two units are never asked to write the same stem within one -// batch, and the shared makeUniqueTag now appends a per-session monotonic counter -// (T1-11 fix) so even same-second units across batches cannot collide. +// Each unit's baseName carries its ordinal ("item-1", "item-2", ...) so two units +// in one batch never share a stem, and makeUniqueTag's per-session monotonic +// counter keeps same-second units across batches from colliding too. namespace { -// RAII snapshot/restore of the project's media-item selection. Batch item capture must -// transiently select exactly one item per render (RENDER_SETTINGS &32 renders whatever is -// selected); the user's ORIGINAL selection must be restored on EVERY exit path — including -// a mid-batch failure or early return — because selection restoration is part of the -// non-destructive invariant. Snapshot on construct (the currently-selected item set), -// restore on destruct (deselect everything, then re-select exactly the snapshot). +// RAII snapshot/restore of the item selection. Batch item capture must transiently +// select exactly one item per render (RENDER_SETTINGS &32 renders whatever is +// selected); the original selection is restored on every exit path — including a +// mid-batch failure — as part of the non-destructive invariant. class ItemSelectionGuard { public: @@ -85,9 +77,8 @@ public: ~ItemSelectionGuard() { - // Deselect every item in the project, then re-select the snapshot — restoring the - // exact original set regardless of what the batch selected in between. Iterate ALL - // items (not just the currently-selected) so any transient selection is cleared. + // Deselect everything first (not just currently-selected) so any transient + // selection is cleared, then re-select exactly the snapshot. const int total = CountMediaItems(nullptr); for (int i = 0; i < total; ++i) if (MediaItem* it = GetMediaItem(nullptr, i)) @@ -104,9 +95,8 @@ private: std::vector selected_; }; -// Selects exactly `item` (deselect-all then select-one) so the offline render's -// selected-items bit (&32) captures a single item. Used inside the batch loop under the -// ItemSelectionGuard, which restores the user's original selection afterward. +// Deselect-all then select-one so the offline render's &32 bit captures exactly +// this item. Called inside ItemSelectionGuard, which restores the original selection. void selectOnlyItem(MediaItem* item) { const int total = CountMediaItems(nullptr); @@ -115,9 +105,8 @@ void selectOnlyItem(MediaItem* item) SetMediaItemSelected(it, it == item); } -// Collects every track's razor AUDIO areas as (owning track, range) pairs, preserving -// track order then area order — the batch analog of resolveRazorRange, which unions them. -// Read-only (never clears the razor selection). Reuses the pure parseRazorEdits parser. +// Collects every track's razor areas as (owning track, range) pairs, track order +// then area order. Read-only — never clears the razor selection. std::vector> collectRazorAreas() { std::vector> areas; @@ -135,10 +124,9 @@ std::vector> collectRazorAreas() return areas; } -// RAII snapshot/restore of the project's TRACK selection. Batch razor capture must -// transiently select exactly the area's owning track per render (track scope's &128 bit -// renders whatever TRACKS are selected); the user's original track selection is restored -// on EVERY exit path (part of the non-destructive invariant). Mirror of ItemSelectionGuard. +// Mirror of ItemSelectionGuard for track selection: batch razor capture transiently +// selects the area's owning track per render (&128 renders selected tracks), restoring +// the original selection on every exit path (non-destructive invariant). class TrackSelectionGuard { public: @@ -170,16 +158,13 @@ private: } // namespace -// Batch item capture: one bank sample per SELECTED item, item scope. Snapshots the -// selection (RAII restore on every path), then for each selected item transiently selects -// only it, renders its exact [pos, pos+len] range under item-scope FX neutralize, adds the -// Sample, and records a per-unit verdict. Persists ONCE at the end (one ext-state write for -// the whole batch). Reports a mixed-result summary (explicit-action response — allowed). +// One sample per selected item. Snapshots the selection (RAII-restored), transiently +// selects each item in turn, renders its exact range under item-scope FX neutralize, +// and persists once at the end for the whole batch. void RunBatchCaptureItems(ReaSamplerSession& session) { - // Read the selected items up front (pointers stay valid — batch mutates only selection - // flags, never adds/removes items). Also capture each item's exact bounds and owning - // track NOW, while the full selection is live, before any transient re-selection. + // Read bounds + owning track now, while the full selection is live and before any + // transient re-selection (batch only mutates selection flags, never adds/removes items). struct ItemUnit { MediaItem* item; MediaTrack* track; double start; double end; }; std::vector itemUnits; { @@ -201,8 +186,8 @@ void RunBatchCaptureItems(ReaSamplerSession& session) return; } - // Plan the exact source ranges -> validated, ordinal-assigned units (pure). Empty/ - // inverted item ranges (a zero-length item) are dropped here so no stray render runs. + // Plan exact ranges -> validated, ordinal-assigned units; zero-length items are + // dropped here so no stray render runs. std::vector ranges; ranges.reserve(itemUnits.size()); for (const ItemUnit& u : itemUnits) @@ -212,19 +197,17 @@ void RunBatchCaptureItems(ReaSamplerSession& session) BatchOutcome outcome; bool anyAdded = false; { - // Restore the user's ORIGINAL item selection on every exit path (incl. early - // return / mid-batch failure) — non-destructive invariant. + // selGuard restores the original item selection on every exit path. ItemSelectionGuard selGuard; - // The plan and itemUnits are parallel over the KEPT units. Walk itemUnits, but only - // for those whose range survived planning (same drop rule), matching by ordinal. + // plan and itemUnits are parallel over kept units; skip dropped ranges in lockstep. std::size_t planIdx = 0; for (const ItemUnit& u : itemUnits) { if (!(u.end > u.start)) continue; // dropped by planCaptureUnits — skip in lockstep const CaptureUnit& unit = plan[planIdx++]; - // Transiently select ONLY this item so the item-scope render captures exactly it. + // select only this item so the item-scope render captures exactly it. selectOnlyItem(u.item); ResolvedSource src; @@ -245,9 +228,9 @@ void RunBatchCaptureItems(ReaSamplerSession& session) } } // selGuard restores the original selection here, on every path - // Persist ONCE for the whole batch (one ext-state write) — only if something landed. - // S9: one bump for the whole batch (coalesced) — the counter is monotonic, not per-sample, - // so a single increment past the last-seen value is enough to trigger one instance reload. + // One ext-state write for the whole batch, only if something landed. The generation + // bump is monotonic, so one increment past the last-seen value triggers reload in + // any listening instance. if (anyAdded) { session.bumpBankGeneration(); session.saveToActiveProject(); @@ -256,12 +239,10 @@ void RunBatchCaptureItems(ReaSamplerSession& session) ShowConsoleMsg((outcome.summaryLine("item") + "\n").c_str()); } -// Batch razor capture: one bank sample per razor AREA, track scope over that area's own -// range (the area's owning track is the source track). Track scope renders the selected -// TRACKS via master (&128), so each unit transiently selects ONLY its owning track -// (SetOnlyTrackSelected) under the TrackSelectionGuard, which restores the user's original -// track selection on every path. The razor selection itself is read-only and left intact. -// Persists ONCE at the end. Reports a mixed-result summary. +// One sample per razor area, track scope over that area's own range. Track scope +// renders selected tracks via master (&128), so each unit selects only its owning +// track under TrackSelectionGuard; the razor selection itself is read-only. Persists +// once at the end. void RunBatchCaptureRazor(ReaSamplerSession& session) { const std::vector> areas = collectRazorAreas(); @@ -280,7 +261,7 @@ void RunBatchCaptureRazor(ReaSamplerSession& session) BatchOutcome outcome; bool anyAdded = false; { - // Restore the user's ORIGINAL track selection on every exit path. + // selGuard restores the original track selection on every exit path. TrackSelectionGuard selGuard; std::size_t planIdx = 0; @@ -290,8 +271,7 @@ void RunBatchCaptureRazor(ReaSamplerSession& session) const CaptureUnit& unit = plan[planIdx++]; MediaTrack* tr = a.first; - // Transiently select ONLY this track so the track-scope render (&128) captures - // exactly it via master (over the custom time bounds we set per unit). + // select only this track so track-scope render (&128) captures it via master. SetOnlyTrackSelected(tr); ResolvedSource src; @@ -312,7 +292,7 @@ void RunBatchCaptureRazor(ReaSamplerSession& session) } } // selGuard restores the original track selection here, on every path - // S9: one coalesced bump for the whole razor batch (see the item-batch note above). + // One coalesced generation bump for the whole batch (see the item-batch note above). if (anyAdded) { session.bumpBankGeneration(); session.saveToActiveProject(); @@ -321,25 +301,15 @@ void RunBatchCaptureRazor(ReaSamplerSession& session) ShowConsoleMsg((outcome.summaryLine("razor area") + "\n").c_str()); } -// --- M10: re-capture from source -------------------------------------------- +// Regenerates a provenanced sample's file from its recorded source's current state +// and updates the bank Sample in place. Bank-only — never calls InsertMedia; the +// user re-places manually if they want the new version on the timeline. +// Non-destructive to the source (FxBypassGuard snapshot/restore via renderOffline). // -// Regenerates a PROVENANCED bank sample's file from its recorded source's CURRENT -// state, then updates the bank Sample IN PLACE. BANK-ONLY — it renders a file and -// refreshes the index entry; it NEVER calls InsertMedia / touches the timeline (the -// load-bearing capture-never-places line, structurally visible: this function has no -// insert path at all). Non-destructive to the source (FxBypassGuard snapshot/restore -// via renderOffline). Fork P2=a: refresh the bank entry only; the user re-places -// manually if they want the new version on the timeline. -// -// Failure modes are handled explicitly and reported to the user (a direct response -// to an explicit action is allowed by the console policy): -// * the selected sample has no provenance (not a resample) -> reported, no-op. -// * the recorded fingerprint is unparseable (legacy/corrupt) -> reported, no-op. -// * the recorded source track(s) no longer exist -> reported, no-op. -// * the render itself fails to satisfy the recorded request -> reported, no-op. -// On success, if the source FX chain drifted since capture (recorded vs current -// identity differ) the user is told — the re-capture still reflects the source AS IT -// IS NOW (P1=a: the fingerprint detects drift, it does not freeze the source). +// Failure modes are explicit and reported, each a no-op: no provenance, unparseable +// fingerprint, a missing recorded source track, or a failed render. On success, if +// the source FX chain drifted since capture, the user is told — the re-capture still +// reflects the source as it is now (drift is detected, not frozen against). void RunRecaptureFromSource(ReaSamplerSession& session) { const std::vector selected = bankPanelSelectedSampleIds(); @@ -371,8 +341,8 @@ void RunRecaptureFromSource(ReaSamplerSession& session) return; } - // Parse the recorded capture recipe from the fingerprint. A legacy / corrupt - // string fails gracefully — never a partial re-capture. + // Parse the recorded recipe; legacy/corrupt fingerprints fail gracefully, never + // a partial re-capture. const std::string recordedParentId = orig->provenance->parentSampleId; const std::string recordedFingerprint = orig->provenance->fxChainSnapshot; const std::optional recipe = @@ -384,8 +354,7 @@ void RunRecaptureFromSource(ReaSamplerSession& session) return; } - // Resolve the recorded source track GUID(s) to live tracks. Any missing track is a - // hard failure — we will not silently re-capture a different source. + // Missing recorded track = hard failure; never silently re-capture a different source. std::vector sourceTracks; for (const std::string& g : recipe->trackGuids) { @@ -400,8 +369,7 @@ void RunRecaptureFromSource(ReaSamplerSession& session) } if (sourceTracks.empty()) { - // The recipe recorded no source tracks (e.g. an item-scope capture whose source - // tracks were not track-scoped). Without a resolvable source we cannot re-run. + // e.g. an item-scope capture with no track-scoped source — nothing to resolve. ShowConsoleMsg("ReaSampler re-capture: no resolvable recorded source for this " "sample; cannot re-capture from source.\n"); return; @@ -411,10 +379,9 @@ void RunRecaptureFromSource(ReaSamplerSession& session) recipe->scope == model::ProvenanceScope::Item ? CaptureScope::Item : CaptureScope::Track; - // Rebuild the capture request verbatim from the recorded recipe — the SAME request, - // re-run against the source's CURRENT state (P1=a). Exact bounds, tail, rate, - // channels, bit depth all match the original so an unchanged source produces a - // byte-identical file (bit-identical-repeats invariant, consumed as a feature). + // Rebuild the request verbatim from the recorded recipe, re-run against the + // source's current state: an unchanged source reproduces a byte-identical file + // (bit-identical-repeats invariant). CaptureRequest req; req.sourceMode = static_cast(recipe->sourceMode); req.startSeconds = recipe->startSeconds; @@ -428,10 +395,9 @@ void RunRecaptureFromSource(ReaSamplerSession& session) req.baseName = orig->displayName.empty() ? "recapture" : orig->displayName; req.trackGuids = recipe->trackGuids; - // Read the CURRENT source FX-chain identity BEFORE the render bypasses it, to - // compare against the recorded identity for drift reporting. Mirror the same - // scope split as buildCaptureProvenance: item scope reads take FX via TakeFX_*; - // track scope reads the track FX chain via TrackFX_*. + // Read the current FX-chain identity BEFORE the render bypasses it, for drift + // comparison against the recorded identity (item scope via TakeFX_*, track scope + // via TrackFX_*). std::string currentIdentity; if (scope == CaptureScope::Item) { const int n = CountSelectedMediaItems(nullptr); @@ -459,11 +425,10 @@ void RunRecaptureFromSource(ReaSamplerSession& session) return; } - // Update the Sample IN PLACE: keep its identity (id) and its provenance thread - // (same parent + a REFRESHED fingerprint reflecting the source as re-captured), but - // adopt the regenerated file's path / hash / length / rate / timestamp. The - // fingerprint is rebuilt from the recipe with the CURRENT FX identity so a - // subsequent re-capture measures drift from this point, not the original. + // Update in place: keep identity (id) + provenance parent, adopt the regenerated + // file's path/hash/length/rate/timestamp, and rebuild the fingerprint with the + // current FX identity so the next re-capture measures drift from here, not the + // original. model::CaptureRecipe refreshed = *recipe; refreshed.fxChainIdentity = currentIdentity; @@ -476,33 +441,29 @@ void RunRecaptureFromSource(ReaSamplerSession& session) updated.sampleRate = res.sample.sampleRate; updated.lengthSeconds = res.sample.lengthSeconds; updated.captureTempo = res.sample.captureTempo; - updated.captureTimeSigNum = res.sample.captureTimeSigNum; // L7 F1: refresh meter stamp + updated.captureTimeSigNum = res.sample.captureTimeSigNum; // refresh meter stamp updated.captureTimeSigDenom = res.sample.captureTimeSigDenom; // to the re-capture's meter updated.trackGuids = res.sample.trackGuids; updated.createdTimestamp = res.sample.createdTimestamp; - // NOTE: levels, clipped, and lengthBeats are carried from the original (via the - // *orig copy above) because the offline backend does not populate them today - // (res.sample leaves them at defaults). If a later milestone populates these - // fields at capture time, refresh them here from res.sample instead. + // levels/clipped/lengthBeats carry from *orig — the offline backend doesn't + // populate them; refresh from res.sample here if that ever changes. model::Provenance prov; prov.parentSampleId = recordedParentId; prov.fxChainSnapshot = model::buildFingerprint(refreshed); updated.provenance = prov; - // Single batched undo point around the in-place bank mutation (mirrors the bank - // action family's R-B pattern). The mutation is index-only ext-state; the render - // wrote a new file but placed nothing on the timeline. + // One batched undo point around the in-place mutation; index-only ext-state, + // nothing placed on the timeline. Undo_BeginBlock2(nullptr); const bool changed = session.book().updateSampleInPlace(sampleId, updated); if (changed) { - // Record the regenerated file in the owned manifest (a new file the tool wrote); - // the superseded old file becomes an orphan reclaimed by Phase R prune. + // Record the regenerated file in the owned manifest; the superseded file + // becomes an orphan for prune to reclaim. session.owned().add(updated.relativePath); - // S9: re-capture-in-place regenerates the SAME id's audio — the exact case the - // hands-free refresh exists for (an instance referencing this id keeps playing the - // OLD audio until it reloads). Bump inside the undo block so undo rolls back the - // generation with the rest of the blob. + // Regenerating the same id's audio is exactly why instances need the generation + // bump — they'd otherwise keep playing stale audio until reload. Bumped inside + // the undo block so undo rolls back the generation with the rest of the blob. session.bumpBankGeneration(); const bool persisted = session.saveToActiveProject(); // book + manifest + generation + MarkProjectDirty Undo_EndBlock2(nullptr, persisted ? "ReaSampler: re-capture from source" : "", diff --git a/src/shell/capture/capture_batch.h b/src/shell/capture/capture_batch.h index f161421..44c89a1 100644 --- a/src/shell/capture/capture_batch.h +++ b/src/shell/capture/capture_batch.h @@ -1,23 +1,13 @@ #pragma once -// capture_batch — the batch-capture family + re-capture-from-source (Q-W3 hoist -// out of main.cpp; the fourth hoist, T4-02 — recapture is planner-driven like -// batch and shares the RAII selection-guard machinery, so it belongs here, not -// with the single-shot path). Owns: -// * RunBatchCaptureItems — one bank sample per SELECTED item (item scope), the -// user's item selection snapshot/restored on every path (ItemSelectionGuard); -// * RunBatchCaptureRazor — one bank sample per razor AREA (track scope over the -// area's own range), the user's track selection snapshot/restored on every -// path (TrackSelectionGuard); -// * RunRecaptureFromSource — regenerate a PROVENANCED bank sample from its -// recorded source's CURRENT state, updating the Sample in place. BANK-ONLY. +// The batch-capture family + re-capture-from-source: RunBatchCaptureItems (one +// sample per selected item), RunBatchCaptureRazor (one sample per razor area), +// RunRecaptureFromSource (regenerate a provenanced sample from its recorded +// source's current state, bank-only, in place). Every unit routes through +// capture_orchestrator so every precision invariant holds; persist is batched to +// one ext-state write per action. // -// Every unit honors every precision invariant via capture_orchestrator's -// captureAndIndexOne / renderOffline (exact bounds, non-destructive neutralize, -// relative paths); nothing here ever touches the arrange/timeline (load-bearing -// principle). Persist is batched: ONE ext-state write per action. -// -// REAPER-facing: the .cpp includes reaper_plugin_functions.h WITHOUT -// REAPERAPI_IMPLEMENT (main.cpp owns the API pointers — CLAUDE.md §contract). +// The .cpp includes reaper_plugin_functions.h WITHOUT REAPERAPI_IMPLEMENT +// (main.cpp owns the API pointers). namespace reasampler { class ReaSamplerSession; diff --git a/src/shell/capture/capture_orchestrator.cpp b/src/shell/capture/capture_orchestrator.cpp index 030e146..124034f 100644 --- a/src/shell/capture/capture_orchestrator.cpp +++ b/src/shell/capture/capture_orchestrator.cpp @@ -1,12 +1,8 @@ -// capture_orchestrator.cpp — the single-capture orchestration + realtime/insert -// action bodies (Q-W3 hoist out of main.cpp; the code moved verbatim, the session -// threaded as a parameter). See the header. FxBypassGuard lives here as a STACK -// RAII object (precision-invariant-critical — it must restore on every exit path -// of exactly one render call). +// See capture_orchestrator.h. FxBypassGuard lives here as a stack RAII object — +// it must restore on every exit path of exactly one render call. // -// 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; here they are extern (CLAUDE.md §contract). +// Includes reaper_plugin_functions.h WITHOUT REAPERAPI_IMPLEMENT — main.cpp is +// the one TU that defines the API pointers; here they are extern. #include "shell/capture/capture_orchestrator.h" diff --git a/src/shell/capture/capture_orchestrator.h b/src/shell/capture/capture_orchestrator.h index d5d9b3b..7dd8d9a 100644 --- a/src/shell/capture/capture_orchestrator.h +++ b/src/shell/capture/capture_orchestrator.h @@ -1,25 +1,17 @@ #pragma once -// capture_orchestrator — the single-capture orchestration + the realtime/insert -// action bodies (Q-W3 hoist out of main.cpp, T4-02). Owns: -// * renderOffline — ONE offline render under the scope's FxBypassGuard (the -// stack-RAII out-of-scope FX/fader/pan neutralize, defined in the .cpp — -// precision-invariant-critical, shared by single-shot / batch / recapture); -// * captureAndIndexOne — render + provenance stamp + bank add + owned-manifest -// record, WITHOUT persisting (single-shot persists right after; batch persists -// once at the end); -// * RunCapture / RunCaptureItemAssign — the bindable single-capture actions; -// * RunCaptureRealtimeTrack / RunCancelRealtime — the realtime action bodies -// (the in-flight state itself lives in realtime_lifecycle); -// * RunInsertSelected — the M6 placement action body (the INTENDED, explicit -// placement path — the one deliberate exception to capture-never-places). +// Single-capture orchestration + the realtime/insert action bodies: renderOffline +// (one offline render under the scope's FxBypassGuard, shared by single-shot/ +// batch/recapture), captureAndIndexOne (render + provenance + bank add + +// owned-manifest record, unpersisted), RunCapture/RunCaptureItemAssign, +// RunCaptureRealtimeTrack/RunCancelRealtime (in-flight state lives in +// realtime_lifecycle), and RunInsertSelected — the one deliberate exception to +// capture-never-places. // -// The session is threaded explicitly (no hidden module state): main.cpp's dispatch -// passes its ReaSamplerSession. The load-bearing principle holds structurally — -// no capture path here calls InsertMedia or touches the arrange/timeline; only -// RunInsertSelected places, on purpose, via the insert shell. +// The session is threaded explicitly; no capture path here calls InsertMedia +// or touches the timeline except RunInsertSelected, on purpose, via the insert shell. // -// REAPER-facing: the .cpp includes reaper_plugin_functions.h WITHOUT -// REAPERAPI_IMPLEMENT (main.cpp owns the API pointers — CLAUDE.md §contract). +// The .cpp includes reaper_plugin_functions.h WITHOUT REAPERAPI_IMPLEMENT +// (main.cpp owns the API pointers). #include @@ -34,17 +26,17 @@ class ReaSamplerSession; namespace reasampler::capture { // Renders one CaptureRequest through the offline backend under the scope's -// FX-bypass guard, returning the backend's CaptureResult. Shared by RunCapture and -// RunRecaptureFromSource so the FX-scope neutralize + render recipe lives in ONE -// place. Non-destructive; touches no timeline item — it writes a file only. +// FX-bypass guard. Shared by RunCapture and RunRecaptureFromSource so the +// FX-scope neutralize + render recipe lives in one place. Non-destructive; +// writes a file only. CaptureResult renderOffline(CaptureScope scope, const std::vector& sourceTracks, const CaptureRequest& req); -// Renders ONE capture request under the scope's FX-bypass guard, stamps provenance, -// and adds the resulting Sample to the ACTIVE bank + records the created file in -// the owned-file manifest — WITHOUT persisting. On success, res.sample.id carries -// the LANDED bank-index id (fresh add or hash-dedup collapse target — S8). +// Renders one capture request, stamps provenance, adds the Sample to the +// active bank + owned-file manifest — without persisting (batch persists once +// at the end). res.sample.id carries the landed bank-index id (fresh add or +// hash-dedup collapse target). CaptureResult captureAndIndexOne(ReaSamplerSession& session, CaptureScope scope, const ResolvedSource& src, @@ -53,21 +45,21 @@ CaptureResult captureAndIndexOne(ReaSamplerSession& session, double endSeconds); // Runs one capture-action-table row: resolve, render + add + record, persist + -// mark dirty. Returns the landed bank-index id ("" on failure/no-op) — the S8 -// capture+assign path consumes it; the plain capture actions ignore it. +// mark dirty. Returns the landed bank-index id ("" on failure/no-op) — the +// arrange-ingest capture+assign path consumes it; plain capture actions ignore it. std::string RunCapture(ReaSamplerSession& session, const CaptureActionDef& def); -// S8 arrange ingest: Item-scope capture into the active bank + assignment-request -// write, in one undo block. +// Item-scope capture into the active bank + assignment-request write, in one +// undo block. void RunCaptureItemAssign(ReaSamplerSession& session); -// STARTS the realtime track capture (async, timer-driven — the in-flight state is -// realtime_lifecycle's; OnTimer drives it) / cancels the in-flight one. +// Starts the realtime track capture (async, timer-driven — in-flight state is +// realtime_lifecycle's) / cancels the in-flight one. void RunCaptureRealtimeTrack(ReaSamplerSession& session); void RunCancelRealtime(ReaSamplerSession& session); -// Runs the M6 insert: place the bank panel's selected sample(s) at the edit cursor -// via the insert shell. `conform` selects the explicit opt-in tempo-match variant. +// Places the bank panel's selected sample(s) at the edit cursor via the +// insert shell. `conform` selects the explicit opt-in tempo-match variant. void RunInsertSelected(ReaSamplerSession& session, bool conform); } // namespace reasampler::capture diff --git a/src/shell/capture/capture_realtime_finalize.cpp b/src/shell/capture/capture_realtime_finalize.cpp index c2a5e9f..7552ab0 100644 --- a/src/shell/capture/capture_realtime_finalize.cpp +++ b/src/shell/capture/capture_realtime_finalize.cpp @@ -1,11 +1,9 @@ -// capture_realtime_finalize.cpp — the FILE-SIDE half of the realtime-record shell -// (Q-W3, T4-08 split): recorded-file discovery, move-into-bank, the Auto-tail PCM -// decay-scan trim, and the finished-Sample population. See the header. The async -// record lifecycle lives in capture_realtime_shell.cpp. +// capture_realtime_finalize.cpp — recorded-file discovery, move-into-bank, the +// Auto-tail decay-scan trim, and finished-Sample population. See the header. // -// Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.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; here they are extern (CLAUDE.md §contract). +// pointers; here they are extern. #include "shell/capture/capture_realtime_finalize.h" @@ -19,7 +17,7 @@ #include "core/capture/capture_realtime.h" // RecordedCapture, sampleFromRecordedCapture #include "core/capture/render_settings.h" // autoTrimEndRatio #include "core/capture/wav_codec.h" // parseWavLayout, extractFloatFrames, planWavTruncate, patchU32LE -#include "core/util/file_bytes.h" // shared whole-file loader (Q-W1, T2-03) +#include "core/util/file_bytes.h" // shared whole-file loader #define REAPERAPI_MINIMAL #define REAPERAPI_WANT_GetTrackNumMediaItems @@ -39,26 +37,21 @@ std::string normSlashes(std::string s) { return s; } -// ============================================================================ -// §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 +// Auto-mode PCM decay-scan trim (docs/product/capture-tail.md §realtime): after the +// recorded file is moved into the bank (the file we OWN, never the project), scan +// the tail (frames after the original range end) backward for the last frame above +// -72 dB and truncate there. Rules: +// * no tail frame above -72 dB -> trim back to the range end +// * signal never drops below -72 dB -> 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. +// Returns the trimmed length in seconds, or negative for "no trim applied". Any +// unreadable/unknown/short file skips the trim rather than risk corrupting the +// capture — this is a convenience path, not a correctness one. // -// 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). +// Assumes the recorded file is a canonical 32-bit float WAV, fully flushed/closed +// before this runs (tick()'s Finalizing size-stable wait guarantees that on the +// normal path; abort()'s best-effort finalize can race it). double trimAutoTailInPlace(const std::string& path, double rangeStartSeconds, double rangeEndSeconds) { @@ -73,21 +66,18 @@ double trimAutoTailInPlace(const std::string& path, 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. + // Range end as a frame index (frame 0 == start), using the file's own sample + // rate (authoritative — the request rate may be 0 = follow project). Clamped to + // the file so a rounding overshoot cannot exceed it. const double rangeSeconds = rangeEndSeconds - rangeStartSeconds; if (rangeSeconds <= 0.0) return kNoTrim; std::size_t rangeEndFrame = static_cast( rangeSeconds * static_cast(layout.sampleRate) + 0.5); if (rangeEndFrame > totalFrames) rangeEndFrame = totalFrames; - // Nothing recorded past the range end (the tail window was empty) -> nothing to - // trim; keep as-is. (Shouldn't happen for Auto, but total by construction.) - if (rangeEndFrame >= totalFrames) return kNoTrim; + if (rangeEndFrame >= totalFrames) return kNoTrim; // tail window was empty - // Scan ONLY the tail region (frames after the original range end). The trim never - // eats into the range body — the scan starts at rangeEndFrame. + // Scan only the tail region — the trim never eats into the range body. const std::size_t tailFrames = totalFrames - rangeEndFrame; const std::vector tailPcm = extractFloatFrames(bytes, layout, rangeEndFrame, tailFrames); @@ -97,11 +87,10 @@ double trimAutoTailInPlace(const std::string& path, const std::size_t lastAbove = audio::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). + // keptFrames: the trimmed file's total frame count. No audible tail frame -> trim + // back to rangeEndFrame; an audible frame at idx -> keep through that frame. The + // "never drops below threshold" case falls out naturally: lastAbove is the final + // tail frame, so keptFrames == totalFrames. std::size_t keptFrames; if (lastAbove == audio::kNoFrameAboveThreshold) { keptFrames = rangeEndFrame; @@ -113,21 +102,17 @@ double trimAutoTailInPlace(const std::string& path, const 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 (wav_codec's patch primitive — the one RIFF owner), 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_codec re-parse test). + // Patch RIFF + data size fields to the kept frame count (wav_codec's patch + // primitive — the one RIFF owner), then rewrite the file as exactly the first + // newFileByteLength bytes. A single truncating write avoids a separate resize + // step and any partial-write window where on-disk sizes and length disagree. patchU32LE(bytes, plan.dataSizeFieldOffset, plan.newDataSize); patchU32LE(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. + // A mid-write failure (full disk, yanked drive) would leave a short file while we + // return kNoTrim, overstating the Sample length. Vanishingly unlikely for a + // just-recorded local file, and this is a convenience path, so a temp-file+ + // atomic-rename isn't warranted; flagged rather than built. std::ofstream out(path, std::ios::binary | std::ios::trunc); if (!out) return kNoTrim; // could not reopen to rewrite — leave the full file out.write(reinterpret_cast(bytes.data()), @@ -190,12 +175,8 @@ CaptureResult finalizeRecording(ReaProject* proj, MediaTrack* temp, 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". + // Only Auto trims; None recorded exact bounds and Manual is a fixed window + // (spec §The realtime path). double trimmedLenSeconds = -1.0; if (request.tailMode == TailMode::Auto) { trimmedLenSeconds = trimAutoTailInPlace(destPath, @@ -203,7 +184,7 @@ CaptureResult finalizeRecording(ReaProject* proj, MediaTrack* temp, request.endSeconds); } - // The pure recorded-capture -> Sample mapping (identity, bounds echo, tier). + // Pure recorded-capture -> Sample mapping (identity, bounds echo, tier). RecordedCapture cap; cap.relativePath = paths.relativePath; cap.uniqueTag = uniqueTag; @@ -218,23 +199,16 @@ CaptureResult finalizeRecording(ReaProject* proj, MediaTrack* temp, result.status = CaptureStatus::Ok; result.sample = sampleFromRecordedCapture(cap); - // The SHARED finished-capture stamp (T2-09 dedupe): trackGuids + channelCount - // (request echo), resolved sampleRate (request rate else PROJECT_SRATE — read - // against the record's OWN project), captureTempo, the capture-start time - // signature (timeSigProj = proj: the realtime path PINS the record's own - // project — the divergence from offline's active-project read, kept - // caller-visible here), the WAV-aware contentHash of the (possibly trimmed) - // bank file, and createdTimestamp. + // Shared finished-capture stamp. timeSigProj = proj: the realtime path pins the + // record's own project (offline reads the active project instead) — the + // divergence is kept caller-visible here. stampCaptureSample(result.sample, request, /*rateProj=*/proj, /*timeSigProj=*/proj, destPath); - // 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. + // The recorded length differs from the request range when a tail was recorded, + // so lengthSeconds must reflect the file, not the range: trimmed length if Auto + // trimmed, else the full recorded window (recordWindowEnd - start; equals the + // exact range when tailMode is None). if (trimmedLenSeconds >= 0.0) { result.sample.lengthSeconds = trimmedLenSeconds; } else { diff --git a/src/shell/capture/capture_realtime_finalize.h b/src/shell/capture/capture_realtime_finalize.h index e1a900f..84111f2 100644 --- a/src/shell/capture/capture_realtime_finalize.h +++ b/src/shell/capture/capture_realtime_finalize.h @@ -1,15 +1,13 @@ #pragma once -// capture_realtime_finalize — the FILE-SIDE half of the realtime-record shell -// (Q-W3, T4-08 split riding the Q-9 rename): discovering the file REAPER actually -// recorded, moving it into the bank, the Auto-tail PCM decay-scan trim, and the -// finished-Sample population. The async record LIFECYCLE (state snapshot/restore, -// begin/tick/abort) lives in capture_realtime_shell.cpp; this half talks to -// wav_codec and the filesystem, not to the transport. +// The file-side half of the realtime-record shell: discovers the file REAPER +// actually recorded, moves it into the bank, runs the Auto-tail decay-scan trim, +// and populates the finished Sample. The async record lifecycle (state +// snapshot/restore, begin/tick/abort) lives in capture_realtime_shell.cpp; this +// half talks to wav_codec and the filesystem, not the transport. // -// REAPER-facing: the .cpp includes reaper_plugin_functions.h WITHOUT -// REAPERAPI_IMPLEMENT (main.cpp owns the API pointers — CLAUDE.md §contract). -// MediaTrack / ReaProject are forward-declared (via capture.h) so this header -// stays SDK-lite. +// The .cpp includes reaper_plugin_functions.h WITHOUT REAPERAPI_IMPLEMENT +// (main.cpp owns the API pointers). MediaTrack/ReaProject are forward-declared +// (via capture.h) so this header stays SDK-lite. #include @@ -18,21 +16,18 @@ namespace reasampler::capture { -// Discovers the file REAPER actually recorded onto the temp track: the first media -// item's active take's source file, forward-slashed. Empty string if nothing was -// recorded (no item / take / source). Also used by the lifecycle's flush wait +// The first media item's active take's source file on the temp track, forward- +// slashed; empty if nothing was recorded. Also used by the lifecycle's flush wait // (size-stable check) before finalize runs. std::string recordedFilePath(MediaTrack* temp); -// Builds a CaptureResult for a finalized recording: discover the recorded file, -// move it into the bank at `paths`, Auto-trim the tail decay in place when the -// request asks for it, and populate the Sample (pure sampleFromRecordedCapture + -// the shared stampCaptureSample — both project reads pinned to `proj`, the -// record's OWN project). Returns Ok + Sample on success, or a RenderFailed result. -// Does NOT restore any snapshotted state — the caller restores unconditionally -// afterward (finalize + restore are separate steps so a finalize failure still -// restores). `recordWindowEnd` is the recorded window end in project seconds -// (>= request.endSeconds when a tail was recorded) — the untrimmed-length source. +// Discovers the recorded file, moves it into the bank at `paths`, Auto-trims the +// tail decay in place when requested, and populates the Sample (project reads +// pinned to `proj`, the record's own project). Does NOT restore any snapshotted +// state — the caller restores unconditionally afterward, even on a finalize +// failure, so finalize and restore stay separate steps. `recordWindowEnd` is the +// recorded window end in project seconds (>= request.endSeconds when a tail was +// recorded). CaptureResult finalizeRecording(ReaProject* proj, MediaTrack* temp, const CaptureRequest& request, const BankPaths& paths, diff --git a/src/shell/capture/capture_realtime_shell.cpp b/src/shell/capture/capture_realtime_shell.cpp index 9539c61..e6ae9b2 100644 --- a/src/shell/capture/capture_realtime_shell.cpp +++ b/src/shell/capture/capture_realtime_shell.cpp @@ -1,78 +1,49 @@ -// capture_realtime_shell.cpp — REAPER-facing realtime-record backend -// (RealtimeRecordBackend): the ASYNC record LIFECYCLE — state snapshot/restore + -// begin/tick/abort. (Renamed from capture_realtime.cpp in Q-W3 — the Q-9 naming -// rider: the PURE module owns the capture_realtime stem, this shell takes the -// suffix, matching drag_out ↔ drag_out_win.) The FILE-SIDE half — recorded-file -// discovery, move-into-bank, Auto-tail trim, Sample population — lives in -// capture_realtime_finalize.cpp (T4-08 split). +// REAPER-facing realtime-record backend (RealtimeRecordBackend): the async record +// lifecycle — state snapshot/restore + begin/tick/abort. The file-side half +// (recorded-file discovery, move-into-bank, Auto-tail trim, Sample population) +// lives in capture_realtime_finalize.cpp. // -// 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; here they are extern (CLAUDE.md §contract). +// Includes reaper_plugin_functions.h WITHOUT REAPERAPI_IMPLEMENT — main.cpp is +// the one TU that defines the API pointers; here they are extern. // -// Captures the requested scope over the requested range by RECORDING in realtime -// (transport-driven) into a hidden temp track, then moves the recorded file into -// the bank as a Sample — non-destructively. This increment implements the TRACK -// scope only (records the selected track's own output). Item realtime is deferred -// (UnsupportedMode) rather than silently half-built. +// Captures the requested scope by recording in realtime into a hidden temp +// track, then moves the recorded file into the bank as a Sample — +// non-destructively. TRACK scope only this increment (the selected track's own +// output); item realtime is deferred (UnsupportedMode) rather than half-built. // -// ============================================================================ -// §ASYNC — timer-driven, no UI block (M8 rework — Daniel: "do it right") -// ============================================================================ -// A realtime record takes (end - start) wall-clock seconds. The earlier spike ran a -// bounded MAIN-THREAD wait for the transport to reach the range end — which FREEZES -// REAPER's UI for the whole record. That is gone. The record is now driven across -// timer ticks: -// begin() — validate, snapshot ALL state to restore, create the temp track, -// route the source-track tap, arm, CSurf_OnRecord, RETURN IMMEDIATELY. -// tick() — (from OnTimer, the same tick as session.poll()) read the transport, -// and on a terminal verdict stop + finalize/abort + RESTORE everything. -// abort() — force-terminate now (shutdown / project switch) + RESTORE everything. +// ASYNC: a realtime record takes (end - start) wall-clock seconds; blocking the +// main thread for that long freezes REAPER's UI. So it's driven across timer +// ticks: begin() validates, snapshots all state to restore, creates the temp +// track, routes the source-track tap, arms, CSurf_OnRecord, and returns +// immediately; tick() (from OnTimer, same tick as session.poll()) reads the +// transport and on a terminal verdict stops + finalizes/aborts + restores +// everything; abort() force-terminates (shutdown/project switch) + restores. // -// The snapshot + restore live on RealtimeCaptureState (below), NOT a function-scope -// RAII guard — because the record spans ticks, no single stack frame outlives it. -// restore() is idempotent (a restored_ latch): every terminal path — normal -// completion, user stop, error, second-capture reject, project switch, unload — -// funnels through the SAME single restore, safe to call once from whichever fires. -// The pure record-mode bookkeeping, the recorded-file->Sample mapping, and the -// completion state machine (advanceRecordPhase) all live in the pure -// core/capture/capture_realtime.{h,cpp} (unit-tested outside the DAW). This TU -// owns only the REAPER-bound lifecycle recipe. +// The snapshot + restore live on RealtimeCaptureState, not a function-scope RAII +// guard, because the record spans ticks — no single stack frame outlives it. +// restore() is idempotent: every terminal path (completion, user stop, error, +// project switch, unload) funnels through the same restore. The pure record-mode +// bookkeeping, recorded-file->Sample mapping, and completion state machine +// (advanceRecordPhase) live in core/capture/capture_realtime (unit-tested +// outside the DAW); this TU owns only the REAPER-bound lifecycle recipe. // -// ============================================================================ -// §TAP — track-output tap (selected track's own output, PRE-parent) -// ============================================================================ -// The recipe: the hidden temp track RECEIVES a send FROM each selected source track -// (CreateTrackSend(source, temp)). The temp track records its OWN output -// (I_RECMODE 3/6, latency-compensated) with B_MAINSEND=0 (it does NOT sum back into -// the master — no feedback, no monitoring double). Multiple selected tracks each get -// a send into the one temp track, so their outputs SUM in the temp track — matching -// how offline track scope handles a multi-track selection. +// TAP: the hidden temp track receives a send FROM each selected source track +// (CreateTrackSend(source, temp)) and records its own output (B_MAINSEND=0, so +// it never sums back into the master — no feedback, no monitoring double). +// Multiple selected tracks sum in the one temp track, matching how offline +// track scope handles a multi-track selection. // -// WHY THIS FAITHFULLY CAPTURES THE TRACK'S OUTPUT — and why NO FxBypassGuard: -// A CreateTrackSend defaults to I_SENDMODE=0 (post-fader) with I_SRCCHAN=0 -// (channel offset 0, (srcchan>>10)==0 => full stereo — SDK ~3302/3304). Post-fader -// taps the source track AFTER its own FX and AFTER its own fader/pan — i.e. exactly -// the track's OWN OUTPUT — but BEFORE the parent/folder/master sums it. The send is -// a branch off the signal at the track's output stage; the parent chain downstream -// of that branch is not in the tapped path AT ALL. So the tap is chain-independent -// BY CONSTRUCTION: there is nothing to neutralize, and FxBypassGuard (which mutates -// the live chain, altering the user's monitoring) is deliberately NOT used. This is -// the realtime analogue of offline track scope (item + the track's own FX + its own -// fader/pan; parent/folder/master excluded), reached without touching any live FX. +// Why this needs no FxBypassGuard: CreateTrackSend defaults to I_SENDMODE=0 +// (post-fader), which taps the source track after its own FX/fader/pan — its +// own output — but before the parent/folder/master sums it. The tap is +// chain-independent by construction: there's nothing downstream of the branch +// point to neutralize. (An earlier spike sent FROM the master, which REAPER +// refuses as a feedback loop and silently recorded nothing — a regular +// track->track send has no such loop.) // -// This ALSO fixes the earlier silent-file bug: that spike sent FROM the master INTO -// a temp track, which REAPER refuses to carry (master->track is a feedback loop), so -// the temp recorded silence. A regular track->track send has no feedback — it works. -// -// Non-destructive: the temp track is deleted on teardown, which removes every send we -// created INTO it (REAPER cannot leave a send dangling to a deleted destination) — so -// NO source track retains any routing change. We never mutate any existing track's -// persistent state; we only add sends FROM the source tracks that vanish with the -// temp track. The selected source tracks are UNCHANGED after capture. -// -// Item realtime is deferred (UnsupportedMode): item scope would need per-item take -// isolation on top of the tap, which is a separate increment. +// Non-destructive: deleting the temp track on teardown removes every send +// created into it (REAPER cannot leave a send dangling to a deleted +// destination), so no source track retains any routing change. #include "shell/capture/capture_realtime_shell.h" @@ -125,10 +96,9 @@ std::string readRppPath() { return std::string(buf.data()); } -// The recorded file's current size in bytes, or -1 if it cannot be resolved yet (no -// item/take/source, or the file does not exist on disk this tick). Used by the flush -// wait to detect stability (size unchanged across a tick) BEFORE moving the file — a -// take REAPER is still flushing on the audio thread grows tick over tick. +// -1 if unresolved yet. Used by the flush wait to detect stability (size +// unchanged across a tick) before moving the file — a take REAPER is still +// flushing grows tick over tick. std::int64_t recordedFileSize(MediaTrack* temp) { const std::string path = recordedFilePath(temp); if (path.empty()) return -1; @@ -140,42 +110,33 @@ std::int64_t recordedFileSize(MediaTrack* temp) { } // namespace -// ============================================================================ -// RealtimeCaptureState — the in-flight snapshot + idempotent restore -// ============================================================================ -// Holds EVERYTHING to restore across the many ticks the record spans (temp track + -// its receive-sum sends, other tracks' I_RECARM, transport, edit cursor, time selection), -// plus the request echo needed to finalize the Sample. restore() is idempotent -// (restored_ latch) and is the single teardown every terminal path calls. +// Holds everything to restore across the many ticks the record spans (temp +// track + its sends, other tracks' I_RECARM, transport, edit cursor, time +// selection), plus the request echo needed to finalize the Sample. restore() +// is idempotent (restored_ latch) — the single teardown every terminal path calls. class RealtimeCaptureState { public: - // Bound at begin(): the record's OWN project (transport reads use *Ex(proj_) so - // a project switch mid-record cannot read the wrong transport), the request - // echo, and the resolved bank paths + tag for finalize. + // Transport reads use *Ex(proj_) so a project switch mid-record can't read + // the wrong transport. ReaProject* proj_ = nullptr; CaptureRequest request_; 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). + // Project seconds, >= request_.endSeconds. A tail mode runs the transport + // past the range end (Auto: +8s cap; Manual: +set length) — this, not + // request_.endSeconds, is what the completion machine waits for. 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 - // destination. So there is no separate send handle to track here. + // Sends created into temp_ are removed automatically when temp_ is + // deleted — no separate send handle to track. MediaTrack* temp_ = nullptr; - // The record phase (pure state machine drives the transition). Starts Recording. RecordPhase phase_ = RecordPhase::Recording; - // Wall-clock anchors for the pure machine's safety ceilings (a steady clock — not - // the play cursor — so a stuck/looping transport is still caught, review §3). - // begunAt_ is set at begin(); finalizingAt_ is set on the Recording->Finalizing - // edge (the transport stop) so the flush wait is bounded from the stop, not begin. + // Steady clock (not the play cursor) so a stuck/looping transport is still + // caught. begunAt_ set at begin(); finalizingAt_ set on the Recording-> + // Finalizing edge so the flush wait is bounded from the stop, not begin. std::chrono::steady_clock::time_point begunAt_{}; std::chrono::steady_clock::time_point finalizingAt_{}; @@ -225,37 +186,26 @@ public: } } - // The single, idempotent teardown. Called on EVERY terminal path (normal - // completion, user stop, error, project switch, unload). Safe to call more than - // once — the restored_ latch makes every call after the first a no-op. Order: - // 1. stop the transport if anything is still running (we own it), - // 2. delete the temp track (drops its receive-sum sends + the recorded item), - // 3. restore every other track's arm, - // 4. restore the time selection + edit cursor. - // Stop the record's OWN project transport if it is still playing/recording. Uses - // the project-scoped OnStopButtonEx(proj_) (not the global CSurf_OnStop) so a - // project switch mid-record — where proj_ is no longer the ACTIVE project — stops - // OUR project's transport, never the foreign now-active one. &1=playing, - // &4=recording. Idempotent to call (the playstate guard makes a repeat a no-op). + // Idempotent teardown called on every terminal path: stop transport if + // still running, delete temp track (drops its sends + recorded item), + // restore other tracks' arm, restore time selection + edit cursor. + // OnStopButtonEx(proj_) is project-scoped, not the global CSurf_OnStop, so + // a project switch mid-record (proj_ no longer active) still stops OUR + // project's transport, never the foreign now-active one. void stopOwnTransport() { if (GetPlayStateEx(proj_) & (1 | 4)) OnStopButtonEx(proj_); } - // Is the captured project STILL OPEN? (review §1 — CRITICAL). If the captured - // project was CLOSED mid-record, proj_/temp_ point at freed memory; - // touching them (stopOwnTransport, DeleteTrack, arm restore) is a use-after-free. - // ValidatePtr2 with a null project validates the ReaProject* itself (the header: - // "proj is ignored if pointer is itself a project"). Every teardown that - // dereferences a captured REAPER object MUST gate on this first. + // If the captured project was closed mid-record, proj_/temp_ point at freed + // memory; touching them is a use-after-free. ValidatePtr2 with a null + // project validates the ReaProject* itself. Every teardown that + // dereferences a captured REAPER object must gate on this first. bool captureProjectStillOpen() const { return proj_ && ValidatePtr2(nullptr, proj_, "ReaProject*"); } - // Drop the handle WITHOUT touching any REAPER state — for the closed-project case - // (review §1). A closed project already reclaimed its temp track, arms, and - // transport; there is nothing to restore and the pointers are freed. Latch - // restored_ so any later terminal path is a no-op (idempotent), but skip every - // REAPER call restore() would make. + // For the closed-project case: a closed project already reclaimed its temp + // track, arms, and transport, so drop the handle without touching REAPER state. void dropWithoutRestore() { restored_ = true; temp_ = nullptr; @@ -266,21 +216,16 @@ public: if (restored_) return; restored_ = true; - // 1. Transport: stop OUR project's if still running (usually already stopped - // by the terminal path's explicit stop-before-finalize — a safe no-op then). stopOwnTransport(); - // 2. Temp track: deleting it drops the source-track sends (REAPER removes every - // send whose destination is deleted — no source track is left mutated) AND the - // recorded arrange item in one move — nothing stays behind (load-bearing). + // Deleting the temp track drops the source-track sends (REAPER removes + // every send whose destination is deleted) and the recorded item in one move. if (temp_) { DeleteTrack(temp_); temp_ = nullptr; } - // 3. Other tracks' record-arm. for (const ArmSnap& s : armSnaps_) SetMediaTrackInfo_Value(s.track, "I_RECARM", s.recarm); armSnaps_.clear(); - // 4. Time selection + edit cursor (no view move, no seek). GetSet_LoopTimeRange(true, false, &tsStart_, &tsEnd_, false); SetEditCurPos(curPos_, false, false); } @@ -294,13 +239,6 @@ private: bool finalized_ = false; }; -// The FILE-SIDE finalize half (recorded-file discovery, move-into-bank, the -// Auto-tail PCM decay-scan trim, and the finished-Sample population) lives in -// capture_realtime_finalize.cpp (T4-08). This TU owns only the async lifecycle. - -// ============================================================================ -// begin — start the record, snapshot, return immediately (no UI block) -// ============================================================================ void RealtimeCaptureStateDeleter::operator()(RealtimeCaptureState* p) const noexcept { delete p; // full type is visible here — keeps capture.h REAPER-free } @@ -309,8 +247,8 @@ RealtimeCaptureHandle RealtimeRecordBackend::begin(const CaptureRequest& request, const std::vector& sourceTracks, CaptureResult& outFailure) { - // Only the track scope is implemented this increment (see §TAP). Item realtime - // is deferred — it needs per-item take isolation on top of the track-output tap. + // Item realtime is deferred — needs per-item take isolation on top of the + // track-output tap. if (request.sourceMode != SourceMode::SelectedTracks) { outFailure.status = CaptureStatus::UnsupportedMode; outFailure.message = "RealtimeRecordBackend implements TRACK scope only this " @@ -354,7 +292,7 @@ RealtimeRecordBackend::begin(const CaptureRequest& request, // .rpp parent. Prompt Save-As once when unsaved; refuse if still unsaved. std::string rppPath = readRppPath(); if (rppPath.empty()) { - Main_SaveProject(proj, true); // DAW-only: opens Save-As, blocks (verify) + Main_SaveProject(proj, true); rppPath = readRppPath(); } if (rppPath.empty()) { @@ -365,68 +303,51 @@ RealtimeRecordBackend::begin(const CaptureRequest& request, const std::string projectDir = normSlashes(std::filesystem::path(rppPath).parent_path().string()); - // --- Build the in-flight state (owns the snapshot + teardown) --------------- RealtimeCaptureHandle st(new RealtimeCaptureState()); st->proj_ = proj; st->request_ = request; - st->uniqueTag_ = makeUniqueTag("rt-"); // shared mint (T1-11 monotonic counter) + st->uniqueTag_ = makeUniqueTag("rt-"); 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. + // Extended past the range end for a tail mode (Auto/Manual), exact for + // None; the extra window is trimmed later (Auto) or kept (Manual). 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 - // terminal path (the restore() latch); an undo point would surface an internal, - // fully-reversed scaffold in the user's undo history for no user-meaningful action. - // Snapshot cursor + time selection, and disarm every OTHER track BEFORE the temp - // track exists (so it is never in the arm snapshot and keeps the arm we set). + // Deliberately NOT wrapped in an undo block — this backend fully restores + // its own state across every terminal path, so an undo point would surface + // an internal, fully-reversed scaffold for no user-meaningful action. + // Disarm every other track BEFORE the temp track exists so it's never in + // the arm snapshot. st->snapshotAndDisarmOthers(); - // Hidden temp track at the end: no default FX/envelopes (clean sink), hidden from - // both panels, B_MAINSEND=0 so it does NOT sum back into the master (monitoring - // invariant — it would otherwise double the tapped tracks in the user's monitoring). + // Hidden temp track: no default FX/envelopes, hidden from both panels, + // B_MAINSEND=0 so it doesn't sum back into the master (would otherwise + // double the tapped tracks in the user's monitoring). const int idx = CountTracks(proj); InsertTrackAtIndex(idx, false); st->temp_ = GetTrack(proj, idx); if (!st->temp_) { outFailure.status = CaptureStatus::RenderFailed; outFailure.message = "Could not create the hidden temp record track."; - st->restore(); // undo the disarm + cursor/time-sel snapshot + st->restore(); return nullptr; } SetMediaTrackInfo_Value(st->temp_, "B_SHOWINTCP", 0.0); SetMediaTrackInfo_Value(st->temp_, "B_SHOWINMIXER", 0.0); SetMediaTrackInfo_Value(st->temp_, "B_MAINSEND", 0.0); - // Route the TRACK-OUTPUT tap: a send FROM each selected source track INTO the temp - // track (CreateTrackSend(source, temp)). The temp records its OWN output, so the - // sends' outputs SUM in it — multiple selected tracks are captured together (same as - // offline track scope). See §TAP for why this faithfully captures each track's own - // output and needs no FxBypassGuard. - // - // Sends default to post-fader (I_SENDMODE 0) and full-stereo (I_SRCCHAN default, - // (srcchan>>10)==0 — SDK ~3302/3304): post-fader = after the source track's FX and - // fader/pan = the track's OWN output, tapped BEFORE the parent sums it. Left at - // defaults deliberately — that IS the track-scope tap point. - // - // DAW-ONLY ASSUMPTION (flag): that a post-fader track->temp send + output-record - // reproduces the track's own output sample-for-sample (latency comp, pan law, - // mono/stereo folding) is the crux to verify live. + // A send FROM each selected source track INTO the temp track; the temp + // records its own output, so sends sum in it — matching offline track + // scope's multi-track handling. Sends default to post-fader/full-stereo, + // left at defaults deliberately — that IS the track-scope tap point. int sendsMade = 0; for (MediaTrack* src : sourceTracks) { if (!src || src == st->temp_) continue; if (CreateTrackSend(src, st->temp_) >= 0) ++sendsMade; } if (sendsMade == 0) { - // Every send failed (should not happen for valid selected tracks). Refuse - // rather than record a guaranteed-silent file. outFailure.status = CaptureStatus::RenderFailed; outFailure.message = "Could not route any selected track into the record tap — " "nothing to capture."; @@ -434,10 +355,8 @@ RealtimeRecordBackend::begin(const CaptureRequest& request, return nullptr; } - // Record-mode values from the pure planner. The temp track records its OWN output; - // it has no FX and unity fader, so its post-fader output equals the summed sends. - // Track scope is fully wet -> PostFader. (The actual track-scope tap point is the - // source sends' default post-fader mode; the temp's recmode only records the sum.) + // The temp track has no FX and unity fader, so its post-fader output + // equals the summed sends; track scope is fully wet -> PostFader. const OutputTap tap = outputTapForWetDry(request.wetDry); const RecordModePlan rec = recordModePlanFor(request.channelCount, tap); SetMediaTrackInfo_Value(st->temp_, "I_RECMODE", static_cast(rec.recMode)); @@ -446,54 +365,41 @@ 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, 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(). + // recordWindowEnd extends past the range end for a tail mode so the + // transport captures the decay; cursor + time selection are restored by restore(). double rs = request.startSeconds, re = st->recordWindowEnd_; GetSet_LoopTimeRange(true, false, &rs, &re, false); SetEditCurPos(request.startSeconds, false, false); - // Start the transport and RETURN. tick() drives the rest across timer ticks. - // - // DAW-ONLY ASSUMPTION (flag): CSurf_OnRecord starts recording and the exact - // range/auto-punch/stop behavior depends on the user's transport settings — not - // header-guaranteed. tick() detects completion via the play cursor reaching the - // range end (the pure state machine), independent of REAPER's auto-punch. + // tick() detects completion via the play cursor reaching the range end + // (the pure state machine), independent of REAPER's auto-punch settings. CSurf_OnRecord(); - // Anchor the wall-clock safety ceiling from here (steady clock — independent of the - // play cursor, so a transport that starts but never advances is still bounded). + // Steady clock, independent of the play cursor, so a transport that starts + // but never advances is still bounded. st->markElapsedStart(); return st; } -// ============================================================================ -// tick — advance the in-flight record; on terminal, finalize/abort + restore -// ============================================================================ RealtimeTickResult RealtimeRecordBackend::tick(RealtimeCaptureState& state) { RealtimeTickResult out; - // If a prior terminal path already tore this down (e.g. abort() then a stray - // tick), do nothing — the state is spent. + // A prior terminal path (e.g. abort()) already tore this down — spent. if (state.restored()) { out.status = RealtimeTickStatus::Failed; return out; } const RecordPhase prevPhase = state.phase_; - // Read the transport bound to the record's OWN project (a project switch cannot - // point these reads at the wrong transport). &4 = recording. Gather everything the - // pure machine needs (transport + wall-clock ceilings + file-flush readiness). + // *Ex(state.proj_) so a project switch can't point these reads at the + // wrong transport. RecordTickInputs inputs; inputs.transport.recording = (GetPlayStateEx(state.proj_) & 4) != 0; inputs.transport.playPosition = GetPlayPositionEx(state.proj_); inputs.elapsedSeconds = state.elapsedSeconds(); - // Deferred-finalize flush check (review §2), only meaningful once stopped. The - // recorded file is READY when its size is a valid positive value AND unchanged - // from the previous tick — REAPER finished flushing/closing the take on the audio - // thread. Comparing across a tick avoids moving a file mid-write (truncated take). + // File is ready when its size is positive and unchanged from the previous + // tick — REAPER finished flushing the take. Comparing across a tick avoids + // moving a file mid-write. if (prevPhase == RecordPhase::Finalizing) { state.markFinalizingStartOnce(); inputs.finalizingSeconds = state.finalizingSeconds(); @@ -502,34 +408,29 @@ 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. + // Waits for the transport to reach the recorded window end (extended for a + // tail mode), not the request's range end — the extra tail window is part + // of the record. state.phase_ = advanceRecordPhase(state.phase_, inputs, state.request_.startSeconds, 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_)) - // — never the global CSurf_OnStop, which would stop whatever project is ACTIVE (a - // foreign one during a project switch), not the record's own. The flush wait then - // proceeds across subsequent ticks before the file is moved. + // On Recording -> Finalizing, stop OUR project's transport once so REAPER + // begins flushing the take; project-scoped so a project switch can't stop + // the wrong (foreign active) project. if (prevPhase == RecordPhase::Recording && isStopRequested(state.phase_)) { state.stopOwnTransport(); - state.markFinalizingStartOnce(); // anchor the flush ceiling from the stop + state.markFinalizingStartOnce(); } if (!isTerminalPhase(state.phase_)) { out.status = RealtimeTickStatus::InProgress; - return out; // keep the OnTimer tick fast — recording or flushing + return out; } - // Terminal (Done: file flushed + stable; Failed: flush ceiling tripped). On Done, - // finalize moves the now-stable file into the bank + builds the Sample. On Failed - // (the flush timeout) there is nothing usable — report RenderFailed. Then restore - // ALL snapshotted state — the non-destructive gate, idempotent + unconditional. + // Done: file flushed + stable, finalize moves it into the bank. Failed: + // flush ceiling tripped, nothing usable. CaptureResult res; if (state.phase_ == RecordPhase::Done) { res = finalizeRecording(state.proj_, state.temp_, state.request_, @@ -550,23 +451,15 @@ RealtimeTickResult RealtimeRecordBackend::tick(RealtimeCaptureState& state) { return out; } -// ============================================================================ -// abort — force-terminate now (shutdown / project switch) + restore -// ============================================================================ RealtimeTickResult RealtimeRecordBackend::abort(RealtimeCaptureState& state) { RealtimeTickResult out; - // Already torn down (idempotent): report Failed and leave it. if (state.restored()) { out.status = RealtimeTickStatus::Failed; return out; } - // CRITICAL (review §1): if the captured project was CLOSED mid-record, proj_ / - // temp_ point at freed memory. The closed project already reclaimed its - // temp track, arms, and transport — so DROP the handle WITHOUT touching any REAPER - // state (no stop, no finalize, no DeleteTrack, no arm restore). Touching those - // freed pointers is the use-after-free bug this guard exists to prevent. This is - // the ONE terminal path that can run against a possibly-closed project (tick() only - // runs while proj_ is the active — hence still-open — project); guarding here covers - // both the project-switch and unload callers. + // If the captured project was closed mid-record, proj_/temp_ point at + // freed memory — drop the handle without touching REAPER state. This is + // the one terminal path that can run against a possibly-closed project + // (tick() only runs while proj_ is still the active project). if (!state.captureProjectStillOpen()) { state.dropWithoutRestore(); out.result.status = CaptureStatus::RenderFailed; @@ -576,25 +469,18 @@ RealtimeTickResult RealtimeRecordBackend::abort(RealtimeCaptureState& state) { return out; } - // The project is still open (a tab-switch, or a clean unload with the project - // present): stop the transport, then TRY to finalize whatever was captured so a - // near-complete record still keeps the audio; if nothing was recorded (or the file - // has not flushed yet), finalize returns RenderFailed and we abort clean. - // Project-scoped stop (OnStopButtonEx(proj_)) — on a project switch proj_ is no - // longer active, so the global CSurf_OnStop would stop the wrong (foreign) project. - // - // NOTE (residual timing — DAW-verify): abort is the force-terminate path (unload / - // switch); it cannot span ticks to wait for the flush the way tick() does, so its - // finalize still races REAPER's audio-thread take close. That is inherent to a - // best-effort terminal grab and is acceptable — the normal completion path (tick) - // is the one that must be flush-safe. + // Project still open: stop the transport, then try to finalize whatever + // was captured so a near-complete record keeps its audio; if nothing + // usable was recorded, finalize returns RenderFailed and we abort clean. + // abort() is the force-terminate path — unlike tick() it can't span ticks + // to wait for the flush, so it still races REAPER's audio-thread take close. state.stopOwnTransport(); CaptureResult res = finalizeRecording(state.proj_, state.temp_, state.request_, state.paths_, state.uniqueTag_, state.recordWindowEnd_); state.markFinalized(); - state.restore(); // the non-destructive gate — always runs + state.restore(); out.result = res; out.status = (res.status == CaptureStatus::Ok) diff --git a/src/shell/capture/capture_realtime_shell.h b/src/shell/capture/capture_realtime_shell.h index 4557b19..f91c90b 100644 --- a/src/shell/capture/capture_realtime_shell.h +++ b/src/shell/capture/capture_realtime_shell.h @@ -1,31 +1,21 @@ #pragma once -// capture_realtime_shell — the ASYNC realtime-record seam (Q-W6 split of the former -// fat capture.h: this header owns the realtime backend's begin/tick/abort surface; -// capture.h keeps the shared CaptureRequest/CaptureResult types, the offline -// backend, and the shared backend helpers). Implemented by -// capture_realtime_shell.cpp; driven by exactly one caller (realtime_lifecycle). +// The ASYNC realtime-record seam: begin/tick/abort. capture.h keeps the shared +// CaptureRequest/CaptureResult types, the offline backend, and shared helpers. +// Implemented by capture_realtime_shell.cpp; driven by exactly one caller +// (realtime_lifecycle). // -// A realtime record is inherently asynchronous: CSurf_OnRecord starts the transport -// on REAPER's audio thread and returns immediately — it does NOT block until the -// range completes, which takes (end - start) wall-clock seconds. Blocking the main -// thread for that duration freezes REAPER's UI, so the realtime backend is DRIVEN -// ACROSS TIMER TICKS instead: begin() starts and returns at once; tick() (called -// from the same OnTimer that runs session.poll()) advances the in-flight record and -// reports when it is done. +// CSurf_OnRecord starts the transport on REAPER's audio thread and returns +// immediately — it does not block until the range completes. Blocking the main +// thread would freeze REAPER's UI, so the backend is driven across timer ticks +// instead: begin() starts and returns at once; tick() (called from the same +// OnTimer that runs session.poll()) advances the in-flight record. // -// SEAM CHOICE (surfaced): the two backends deliberately share NO interface. The -// lifecycles are genuinely different (offline is headless + immediate — one -// synchronous capture() call returns a finished Sample; realtime is -// transport-driven + async — begin/tick/abort across timer ticks), so a shared -// interface would make offline fake a lifecycle it does not have (its tick() -// would always be Done on the first call — dead code / an LSP smell). Offline -// stays synchronous; the realtime backend owns this small bespoke async seam. -// This is the split-sync/async fork, chosen over a unified async interface for -// that reason. (The old synchronous ICaptureBackend interface over -// OfflineRenderBackend was deleted in Q-W3 — T4-26: one deriver, zero polymorphic -// call sites.) +// The two backends deliberately share NO interface — do not reintroduce one. +// Offline is headless + immediate (one synchronous capture() call); realtime is +// transport-driven + async. A shared interface would make offline fake a +// lifecycle it doesn't have (tick() always Done on first call). // -// REAPER-free like capture.h: MediaTrack is forward-declared there and never +// REAPER-free like capture.h: MediaTrack is forward-declared there, never // dereferenced here; the REAPER-facing TU is capture_realtime_shell.cpp. #include @@ -47,74 +37,64 @@ struct RealtimeTickResult { CaptureResult result; // meaningful only when status == Done or Failed }; -// The opaque in-flight capture state. Owns the snapshot of everything to restore -// (temp track + its receive sends from the source tracks, other tracks' I_RECARM, +// The opaque in-flight capture state: the snapshot of everything to restore +// (temp track + its sends from the source tracks, other tracks' I_RECARM, // transport, edit cursor, time selection) and the record's own project handle. -// Defined in capture_realtime_shell.cpp; the header stays REAPER-free (nothing is -// dereferenced here) by holding it behind a forward-declared type + unique_ptr. +// Defined in capture_realtime_shell.cpp; forward-declared here to stay REAPER-free. // -// restore()/teardown is idempotent and lives ON THIS OBJECT (not a function-scope -// RAII guard) because the record spans ticks — no single stack frame outlives it. -// Every terminal path (normal completion, user stop, error, project switch, unload) -// funnels through the same single restore, safe to call once from whichever fires. +// restore()/teardown is idempotent and lives ON THIS OBJECT, not a function-scope +// RAII guard, because the record spans ticks — no single stack frame outlives it. +// Every terminal path (completion, user stop, error, project switch, unload) +// funnels through the same restore, safe to call once from whichever fires. class RealtimeCaptureState; -// Out-of-line deleter so callers (realtime_lifecycle) can own a unique_ptr to the -// opaque RealtimeCaptureState WITHOUT its full (REAPER-typed) definition — the -// delete is compiled in capture_realtime_shell.cpp where the type is complete, -// keeping this header REAPER-free (load-bearing split). +// Out-of-line deleter so callers can own a unique_ptr to the opaque +// RealtimeCaptureState without its full (REAPER-typed) definition. struct RealtimeCaptureStateDeleter { void operator()(RealtimeCaptureState* p) const noexcept; }; using RealtimeCaptureHandle = std::unique_ptr; -// Realtime-record backend — captures by RECORDING in realtime (transport-driven) -// into a hidden temp track, then moves the recorded file into the bank as a Sample. -// For sources offline render cannot do (hardware, performed FX) and as the true -// pre-FX-dry path (I_RECMODE_FLAGS &3==1 — the only pre-FX tap in the SDK; offline -// render has none). Dialog-free: never invokes the offline-render progress window. +// Captures by recording in realtime into a hidden temp track, then moves the +// recorded file into the bank as a Sample. For sources offline render can't do +// (hardware, performed FX) and as the true pre-FX-dry path (I_RECMODE_FLAGS +// &3==1 — the only pre-FX tap in the SDK). Dialog-free: never invokes the +// offline-render progress window. // -// Non-bit-identical by nature (it is realtime); offline stays the deterministic -// default. Non-destructive across EVERY terminal path — the review gate — which is -// harder here than offline because the record spans ticks: the snapshot + restore -// live on RealtimeCaptureState, not a function-scope RAII destructor. +// Non-bit-identical by nature; offline stays the deterministic default. +// Non-destructive across every terminal path is harder here than offline +// because the record spans ticks: snapshot + restore live on +// RealtimeCaptureState, not a function-scope RAII destructor. // -// SCOPE (this increment): TRACK scope only — records the selected track's OWN -// output (item + that track's own FX + its own fader/pan, PRE-parent), matching -// offline's track scope. This needs NO FxBypassGuard: a send tapping a track's -// output is naturally PRE-parent (the parent has not summed it yet), so the tap is -// chain-independent by construction. Item realtime is deferred (UnsupportedMode). +// TRACK scope only (this increment): records the selected track's own output +// (item + track's own FX/fader/pan, pre-parent), matching offline's track +// scope. Needs no FxBypassGuard — a send tapping a track's output is naturally +// pre-parent, so the tap is chain-independent by construction. Item realtime +// is deferred (UnsupportedMode). class RealtimeRecordBackend { public: - // Starts a realtime record: validates the request (track scope, non-empty range, - // at least one source track, active + saved project, transport idle), snapshots - // all state to restore, creates the hidden temp track, routes a send FROM each - // source track INTO the temp track, arms, and CSurf_OnRecord — then returns - // IMMEDIATELY (no wait, no UI block). `sourceTracks` are the selected tracks to - // tap (resolved by the action layer — the CaptureRequest itself stays REAPER-free, - // carrying only the provenance GUIDs). On success the returned unique_ptr owns the - // in-flight state; drive it with tick(). On a validation/setup failure returns - // nullptr and fills `outFailure` with the CaptureStatus + message (nothing was - // left mutated — begin() restores on its own failure paths). + // Validates the request (track scope, non-empty range, >=1 source track, + // active+saved project, transport idle), snapshots state, creates the hidden + // temp track, routes a send from each source track into it, arms, and + // CSurf_OnRecord — then returns immediately. `sourceTracks` are resolved by + // the caller; CaptureRequest itself stays REAPER-free. On success the + // returned unique_ptr owns the in-flight state; on failure returns nullptr + // with `outFailure` filled (nothing left mutated). RealtimeCaptureHandle begin(const CaptureRequest& request, const std::vector& sourceTracks, CaptureResult& outFailure); - // Advances the in-flight record one tick. Reads the transport (bound to the - // record's OWN project handle so a project switch cannot confuse it), and on a - // terminal verdict stops the transport, finalizes the recorded file into the - // bank Sample (Done) or reports the failure (Failed), then restores ALL - // snapshotted state. Returns InProgress while the record is still running. - // After Done/Failed the state is spent — the caller drops the unique_ptr. + // Reads the transport (bound to the record's OWN project handle so a project + // switch can't confuse it); on a terminal verdict stops the transport, + // finalizes the recorded file (Done) or reports the failure (Failed), then + // restores all snapshotted state. After Done/Failed the state is spent. RealtimeTickResult tick(RealtimeCaptureState& state); - // Force-terminate an in-flight record NOW without waiting for the range end: - // stops the transport, finalizes whatever was captured (best effort) or abandons - // it, and restores ALL snapshotted state. For the shutdown / project-switch - // paths (extension unload, a new project became active) where the record must - // not leak a temp track / armed track / altered transport into the user's - // project. Idempotent — safe even if a prior tick already tore the state down. + // Force-terminate now without waiting for the range end: stops transport, + // finalizes best-effort or abandons, restores all snapshotted state. For + // shutdown/project-switch paths where the record must not leak a temp + // track/armed track/altered transport. Idempotent. RealtimeTickResult abort(RealtimeCaptureState& state); }; diff --git a/src/shell/capture/insert.cpp b/src/shell/capture/insert.cpp index fc6e5f2..412197f 100644 --- a/src/shell/capture/insert.cpp +++ b/src/shell/capture/insert.cpp @@ -1,34 +1,27 @@ -// insert.cpp — REAPER-facing placement shell (M6). See insert.h. +// insert.cpp — REAPER-facing placement shell. See insert.h. // -// Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.h +// Compiled into the reaper_reasampler module. Includes reaper_plugin_functions.h // WITHOUT REAPERAPI_IMPLEMENT — main.cpp owns the API pointers; here they are -// extern (CLAUDE.md §contract). +// extern. // -// THIS IS THE INTENDED PLACEMENT PATH. Unlike capture / bank_panel (which never -// touch the arrange), insert deliberately adds items to the arrange — that is its -// whole job (CONTEXT.md §load-bearing principle). It runs ONLY from its own action. +// Unlike capture / bank_panel (which never touch the arrange), insert deliberately +// adds items to the arrange — that is its whole job. Runs only from its own action. // -// FLAGGED RUNTIME ASSUMPTIONS (semantics the header does not fully specify — must -// be DAW-verified by Daniel post-merge; see the handoff): -// A. InsertMedia base mode 0 ("add to current track") targets the track that is -// currently the ONLY selected track. The header names the base target but does -// not spell out how "current track" resolves at runtime. We force exactly one -// selected track via SetOnlyTrackSelected before each InsertMedia call, which -// is the most defensible interpretation; if REAPER uses a different notion of -// "current" (e.g. last-focused, not last-selected), DAW-verify and adjust. -// B. InsertMedia mode 0 inserts AT THE EDIT CURSOR. Placement at the edit cursor -// is REAPER's documented convention for base modes 0/1 (the header does not -// spell out an explicit "at edit cursor" bit). Flagged for DAW-verification. -// C. InsertMedia ADVANCES the edit cursor to the end of the inserted media. We -// reset the cursor to the snapshot position before EACH track's insert, so -// assumption C's truth or falsity is irrelevant: we own the cursor reset. -// D. SetEditCurPos(time, false, false) moves the cursor without scrolling the view -// and without seeking the transport. The header lists the args as -// (time, moveview, seekplay) — moveview=false and seekplay=false are the -// non-disruptive choice; flagged in case the DAW shows otherwise. -// E. SetOnlyTrackSelected deselects all tracks and selects exactly one. The header -// doc-comment says "Set exactly one track selected, deselect all others" — -// this is the strongest confirmation we have; flagged for DAW-verification. +// Runtime assumptions the SDK header doesn't fully spell out (flagged, not yet +// DAW-verified): +// A. InsertMedia mode 0 ("add to current track") is assumed to target the sole +// selected track — the header doesn't spell out how "current" resolves, so we +// force exactly one selection via SetOnlyTrackSelected before each call. If +// REAPER means last-focused rather than last-selected, this needs revisiting. +// B. Mode 0 is assumed to insert at the edit cursor (REAPER's documented +// convention for base modes 0/1; the header has no explicit "at cursor" bit). +// C. InsertMedia may advance the cursor to the end of the inserted media; we +// reset to the snapshot position before each track's insert, so this doesn't +// matter either way. +// D. SetEditCurPos(time, false, false) — moveview=false, seekplay=false — is +// assumed to move the cursor without scrolling the view or the transport. +// E. SetOnlyTrackSelected deselects all tracks and selects exactly one (per its +// header doc-comment — the strongest confirmation we have here). #include "shell/capture/insert.h" @@ -57,7 +50,6 @@ namespace reasampler { -// Real-namespace-home using-declarations (Q-W6: the namespaces.h shim is retired). using capture::computeInsertMode; using capture::normalizeSlashes; using capture::resolveBankFile; @@ -67,11 +59,10 @@ namespace { namespace fs = std::filesystem; -// The current project's directory (mirrors bank_panel/capture/persist). The bank -// index stores relative paths; resolving a bank file needs the current .rpp dir. -// FOLLOW-UP (already noted in panel_bank_ops.cpp): a shared "current project dir" -// REAPER helper is a clean small refactor now that a fourth consumer exists — out -// of scope for M6. +// Mirrors bank_panel/capture/persist's own derivation; the bank index stores +// relative paths, so resolving a file needs the current .rpp dir. A shared "current +// project dir" helper would be a clean small refactor now that a fourth consumer +// exists (also noted in panel_bank_ops.cpp) — out of scope here. std::string currentProjectDir() { std::vector buf(4096, '\0'); EnumProjects(-1, buf.data(), static_cast(buf.size())); @@ -80,9 +71,8 @@ std::string currentProjectDir() { return normalizeSlashes(fs::path(rpp).parent_path().string()); } -// Snapshot the user's currently-selected track set (ignores master, matches -// CountSelectedTracks / GetSelectedTrack which both skip master). Returns the -// tracks in selection order so we can restore the original state afterward. +// Snapshot of the currently-selected track set (master is skipped, matching +// CountSelectedTracks/GetSelectedTrack), in selection order, for restore later. std::vector snapshotSelectedTracks() { const int n = CountSelectedTracks(nullptr); // nullptr = active project std::vector tracks; @@ -92,12 +82,10 @@ std::vector snapshotSelectedTracks() { return tracks; } -// Restore a previously-snapshotted track selection: deselect all (by setting the -// first track alone) then re-select the full set. If the snapshot is empty we -// leave all tracks deselected; no-op guard handles a completely empty project. +// Restores a snapshotted selection: deselect all via the first track, then +// re-select the rest. Empty snapshot -> no-op (guards an empty project). void restoreSelectedTracks(const std::vector& tracks) { if (tracks.empty()) return; - // Deselect all via the first track, then re-add the rest. SetOnlyTrackSelected(tracks[0]); for (size_t i = 1; i < tracks.size(); ++i) SetTrackSelected(tracks[i], true); @@ -109,9 +97,8 @@ InsertResult runInsert(ReaSamplerSession* session, const InsertRequest& request) InsertResult result; if (!session) { result.status = InsertStatus::NoSelection; return result; } - // WHO to target: the user's currently-selected track set. No-op (with a clear - // console message) when nothing is selected — inserting without a target track - // would create an unintended new track or behave unpredictably. + // WHO: the user's selected track set. No-op when nothing is selected — inserting + // without a target track would create an unintended track or behave unpredictably. const std::vector selectedTracks = snapshotSelectedTracks(); if (selectedTracks.empty()) { ShowConsoleMsg("ReaSampler insert: select a track first.\n"); @@ -119,22 +106,20 @@ InsertResult runInsert(ReaSamplerSession* session, const InsertRequest& request) return result; } - // WHAT to place: the single focused sample from the panel. Multi-select is - // deprioritized; take the first (or only) selected id. An empty panel selection - // is a no-op — nothing to place. + // WHAT: the single focused sample from the panel; multi-select is deprioritized, + // so take the first id. Empty selection -> no-op. const std::vector ids = bankPanelSelectedSampleIds(); if (ids.empty()) { result.status = InsertStatus::NoSelection; return result; } const std::string& id = ids.front(); // focused / first selected — single sample - // WHERE the bank lives on disk. An unsaved project has no resolvable bank dir; - // insert is a no-op rather than resolving against CWD (CLAUDE.md invariant). + // WHERE: an unsaved project has no resolvable bank dir; no-op rather than + // resolving against CWD. const std::string projectDir = currentProjectDir(); if (projectDir.empty()) { result.status = InsertStatus::NoProject; return result; } - // Resolve the id against the bank the SELECTION came from — under B4's vertical - // split the selection may live in the pool or a shown named bank, which is NOT - // necessarily the active/capture-target bank. Fall back to the active bank when - // the source id names no bank (defensive). + // Resolve against the bank the selection came from — it may be the pool or a + // shown named bank, not necessarily the active/capture-target bank. Fall back to + // the active bank when the source id names no bank (defensive). const std::string srcBankId = bankPanelSelectedSourceBankId(); const BankModel* srcIndex = session->book().index(srcBankId); const BankModel& bank = srcIndex ? *srcIndex : session->bank(); @@ -153,16 +138,14 @@ InsertResult runInsert(ReaSamplerSession* session, const InsertRequest& request) // position for each track insert (and after the whole operation). const double cursorPos = GetCursorPosition(); - // Wrap the whole placement (all tracks + selection/cursor save-restore) in ONE - // undo block so a single undo removes every item and restores the state before - // the action. Opened before the first InsertMedia, closed after the restore, - // unconditionally — the block is always balanced. + // One undo block around the whole placement (all tracks + selection/cursor + // restore) so a single undo removes every item and restores prior state. Always + // balanced — opened before the first insert, closed after the restore. Undo_BeginBlock2(nullptr); - // Insert onto EACH selected track at the SAME edit-cursor position (assumption B). - // For each track: isolate it as the only selection so InsertMedia mode 0 targets - // it unambiguously (assumption A + E), reset the cursor to the snapshot position - // (assumption C cursor advance is irrelevant — we own the reset), then insert. + // Insert onto each selected track at the same cursor position (assumption B): per + // track, isolate it as the only selection (A + E), reset the cursor (C is + // irrelevant since we own the reset), then insert. for (MediaTrack* track : selectedTracks) { SetOnlyTrackSelected(track); // assumption A + E SetEditCurPos(cursorPos, false, false); // assumption D @@ -170,14 +153,12 @@ InsertResult runInsert(ReaSamplerSession* session, const InsertRequest& request) ++result.inserted; } - // Restore the user's original track selection and cursor position so the action - // is non-destructive to their DAW state (non-negotiable per the brief). + // Restore the original selection + cursor — non-destructive to the user's DAW state. restoreSelectedTracks(selectedTracks); SetEditCurPos(cursorPos, false, false); - // Label reflects the count and the conform choice so the undo history reads - // clearly ("ReaSampler: insert on 2 tracks" etc.). extraflags -1 = UNDO_STATE_ALL - // (superset: tracks, items, envelope points, project state). + // Label reflects count + conform choice for a clear undo history. extraflags -1 = + // UNDO_STATE_ALL (tracks, items, envelope points, project state). const std::string label = "ReaSampler: insert on " + std::to_string(result.inserted) + (result.inserted == 1 ? " track" : " tracks") + diff --git a/src/shell/capture/insert.h b/src/shell/capture/insert.h index cb53934..1255e80 100644 --- a/src/shell/capture/insert.h +++ b/src/shell/capture/insert.h @@ -1,21 +1,17 @@ #pragma once -// insert — placement of bank samples into the arrange (M6). REAPER-facing shell: -// it reads the bank_panel's current selection, resolves each selected sample's -// file, and drops it into the arrange at the edit cursor via InsertMedia, wrapped -// in an undo block. +// Placement of bank samples into the arrange. REAPER-facing shell: reads the +// bank_panel's current selection, resolves each selected sample's file, and drops +// it into the arrange at the edit cursor via InsertMedia, wrapped in an undo block. // -// THE INTENDED PLACEMENT PATH (CONTEXT.md §load-bearing principle): capture NEVER -// auto-inserts; `insert` is the deliberate, user-invoked placement act, so it IS -// allowed and expected to add items to the arrange. It must only ever run from its -// own action — never from a capture path. +// The deliberate, user-invoked placement act (root CLAUDE.md §load-bearing +// principle: capture never auto-inserts) — must only ever run from its own action, +// never from a capture path. // -// Non-destructive to the bank: insert references the bank file (adds an arrange -// item pointing at it); it never modifies the bank, the bank files, or ext state. -// No SILENT time-stretch: conform-to-tempo is an explicit opt-in on the request, -// defaulting OFF (native length). See insert_plan for the mode-bit computation. +// Non-destructive to the bank: references the bank file, never modifies it or ext +// state. No silent time-stretch: conform-to-tempo is an explicit opt-in, defaulting +// off (native length) — see insert_plan for the mode-bit computation. // -// The header is SDK-free: all REAPER API use lives in insert.cpp. The pure -// mode-bit arithmetic lives in insert_plan (unit-tested outside the DAW). +// SDK-free header; all REAPER API use lives in insert.cpp. #include "core/capture/insert_plan.h" @@ -32,16 +28,16 @@ struct InsertRequest { // The outcome of an insert action, for the caller to log to the console. enum class InsertStatus { - Ok, // one or more samples inserted - NoSelection, // the panel had no selection — a no-op (not an error) + Ok, + NoSelection, // the panel had no selection — a no-op, not an error NoProject, // no saved project, so no resolvable bank dir — no-op NothingResolved, // a selection existed but no sample resolved to a file }; struct InsertResult { InsertStatus status = InsertStatus::NoSelection; - int inserted = 0; // how many samples were actually placed - int skipped = 0; // selected-but-unresolvable/unreadable samples skipped + int inserted = 0; + int skipped = 0; // selected-but-unresolvable/unreadable samples }; // Runs the insert: reads the bank panel's single focused sample and the user's diff --git a/src/shell/capture/item_read.cpp b/src/shell/capture/item_read.cpp index afc640f..8db3dfb 100644 --- a/src/shell/capture/item_read.cpp +++ b/src/shell/capture/item_read.cpp @@ -1,7 +1,7 @@ // item_read.cpp — the single MediaItem* read seam (GUID + fixed-lane name). See -// item_read.h. Compiled into the reaper_reasampler MODULE; includes +// 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). +// defines the API pointers). #include "shell/capture/item_read.h" diff --git a/src/shell/capture/item_read.h b/src/shell/capture/item_read.h index 7bb74e9..157c34b 100644 --- a/src/shell/capture/item_read.h +++ b/src/shell/capture/item_read.h @@ -1,16 +1,14 @@ #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. +// 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 — the item-read analog of +// track_guid's single MediaTrack* -> GUID-key formatter. Extracted from +// near-identical private itemGuid/itemLaneName pairs previously duplicated in +// view.cpp and bank_panel.cpp. // -// 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). +// The .cpp includes reaper_plugin_functions.h WITHOUT REAPERAPI_IMPLEMENT +// (main.cpp owns the API pointers). MediaItem/MediaTrack are forward-declared so +// this header stays SDK-lite. These are shell reads; the managed/manual decision +// that consumes the lane name stays pure in lane_keys (isOnManualLane). #include diff --git a/src/shell/capture/provenance_shell.cpp b/src/shell/capture/provenance_shell.cpp index 632f68e..dff6607 100644 --- a/src/shell/capture/provenance_shell.cpp +++ b/src/shell/capture/provenance_shell.cpp @@ -1,23 +1,9 @@ -// provenance_shell.cpp — the REAPER reads behind Milestone 10. See provenance_shell.h. +// provenance_shell.cpp — the REAPER reads behind provenance. See provenance_shell.h. +// Every REAPER symbol used here is verified against +// vendor/reaper-sdk/sdk/reaper_plugin_functions.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). Every REAPER symbol used here is verified against -// vendor/reaper-sdk/sdk/reaper_plugin_functions.h: -// * TrackFX_GetCount(MediaTrack*) (~7283) -// * TrackFX_GetFXName(MediaTrack*, int, char*, int) -> bool (~7356) -// * TrackFX_GetFXGUID(MediaTrack*, int) -> GUID* (~7348) -// * TrackFX_GetEnabled(MediaTrack*, int) -> bool (~7291) -// * TakeFX_GetCount(MediaItem_Take*) (~6710) -// * TakeFX_GetFXName(MediaItem_Take*, int, char*, int) -> bool (~6758) -// * TakeFX_GetFXGUID(MediaItem_Take*, int) -> GUID* (~6750) -// * TakeFX_GetEnabled(MediaItem_Take*, int) -> bool (~6718) -// * CountSelectedMediaItems / GetSelectedMediaItem (selection reads) -// * GetActiveTake(MediaItem*) -> MediaItem_Take* (active take) -// * GetMediaItemTake_Source(MediaItem_Take*) -> PCM_source* (~2053) -// * GetMediaSourceFileName(PCM_source*, char*, int) (~2141) -// * CountTracks / GetTrack (track scan) -// * guidToString (via track_guid) +// 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. #include "shell/capture/provenance_shell.h" @@ -51,7 +37,6 @@ namespace reasampler { -// Real-namespace-home using-declarations (Q-W6: the namespaces.h shim is retired). using capture::normalizeSlashes; using capture::resolveBankFile; @@ -80,9 +65,8 @@ std::string fxChainIdentityForTrack(MediaTrack* tr) { } std::string fxChainIdentityForItems(const std::vector& items) { - // For Item scope the in-scope chain is each item's active take's FX chain, NOT - // the owning track's FX chain (the track chain is out-of-scope and is bypassed - // during render). TakeFX_* is the correct family here. + // The owning track's chain is out of scope for an item capture (bypassed + // during render) — TakeFX_* on the active take is the correct family here. std::vector perItem; perItem.reserve(items.size()); for (MediaItem* it : items) { diff --git a/src/shell/capture/provenance_shell.h b/src/shell/capture/provenance_shell.h index ab2cafa..4279db9 100644 --- a/src/shell/capture/provenance_shell.h +++ b/src/shell/capture/provenance_shell.h @@ -1,19 +1,16 @@ #pragma once -// provenance_shell — the REAPER-facing reads Milestone 10 needs, in one place. -// -// The PURE provenance module (provenance.h) owns the fingerprint encoding, the -// recipe model, the FX-identity fold, and the parent-detection DECISION — all over -// plain strings/values. This shell gathers those strings/values FROM REAPER: +// The REAPER-facing reads provenance needs, in one place. The pure provenance +// module (provenance.h) owns the fingerprint encoding, the recipe model, the +// FX-identity fold, and the parent-detection decision, all over plain +// strings/values; this shell gathers those strings/values from REAPER: // * the in-scope FX-chain identity of a source track (name/GUID/enabled rows), // * the media-file paths of a resolved capture's source items, // * the active book's bank samples resolved to absolute file paths, // * a canonical track-GUID string back to a live MediaTrack*. // -// REAPER-facing: the .cpp includes reaper_plugin_functions.h WITHOUT -// REAPERAPI_IMPLEMENT (main.cpp owns the API pointers; here they are extern — -// CLAUDE.md §contract). MediaTrack is forward-declared so this header stays -// SDK-lite. It depends on the pure provenance module (FxIdentityEntry / recipe / -// BankFileRef) and bank_book (to enumerate the active book's samples). +// The .cpp includes reaper_plugin_functions.h WITHOUT REAPERAPI_IMPLEMENT +// (main.cpp owns the API pointers). MediaTrack is forward-declared so this header +// stays SDK-lite. #include #include @@ -28,53 +25,43 @@ namespace reasampler { class BankBook; -// Real-namespace-home using-declaration (Q-W6: the namespaces.h shim is retired). using model::BankFileRef; // The in-scope FX-chain identity of a source track (Track scope), folded to the -// pure provenance string. Reads the track's own FX chain via TrackFX_GetCount / -// TrackFX_GetFXName / TrackFX_GetFXGUID / TrackFX_GetEnabled in chain order. +// pure provenance string via the track's own FX chain (TrackFX_*) in chain order. std::string fxChainIdentityForTrack(MediaTrack* tr); // The in-scope FX-chain identity for Item scope: enumerates each item's active -// take FX chain via TakeFX_GetCount / TakeFX_GetFXName / TakeFX_GetFXGUID / -// TakeFX_GetEnabled, in item order then FX order, combined with -// combineChainIdentities so distinct per-item partitions never collide. Returns -// the combined identity string (empty combined identity for a no-FX or no-item -// set). The items vector is the same source-item set the shell collected for the -// item-scope capture (selected items whose owning tracks were also collected). +// take FX chain (TakeFX_*), in item order then FX order, combined with +// combineChainIdentities so distinct per-item partitions never collide. `items` is +// the same source-item set the shell collected for the item-scope capture. std::string fxChainIdentityForItems(const std::vector& items); -// Reads the media-file path of every SELECTED media item's active take source -// (GetMediaItemTake_Source -> GetMediaSourceFileName), normalized to forward-slash. -// Unresolvable items (no take / no source / empty name) are omitted — never an -// empty string in the result, so detectParent's "not in bank" branch is honest. -// The active-project selection is read directly (mirrors main.cpp's collectors). -// This is the ITEM-scope source set (the user selected the items being resampled). +// The media-file path of every selected media item's active take source, +// normalized to forward-slash. Unresolvable items (no take/source/name) are +// omitted — never an empty string in the result, so detectParent's "not in bank" +// branch is honest. This is the item-scope source set. std::vector selectedItemSourceFiles(); -// The TRACK-scope source set: the media-file paths of the items ON `tracks` that -// OVERLAP the capture range [startSeconds, endSeconds). For a track capture the user -// selects the track, not the item, so the "what audio is being captured" set is the -// range-overlapping items on the source tracks. Same normalize + omit-unresolvable -// contract as selectedItemSourceFiles. An item overlaps iff its [pos, pos+len) -// intersects the range with positive overlap (a zero-length touch does not count). +// The track-scope source set: media-file paths of the items on `tracks` that +// overlap the capture range [startSeconds, endSeconds). A track capture selects +// the track, not the item, so this is what "the source audio" means for it. Same +// normalize + omit-unresolvable contract as selectedItemSourceFiles; an item +// overlaps iff its [pos, pos+len) intersects the range with positive overlap (a +// zero-length touch does not count). std::vector trackItemSourceFiles(const std::vector& tracks, double startSeconds, double endSeconds); -// Enumerates the ACTIVE book's samples across every bank (pool + named) as pure -// BankFileRefs — each sample id paired with its file resolved to a normalized -// ABSOLUTE path against `projectDir` (resolveBankFile + normalizeSlashes). A sample -// whose path cannot be resolved (empty projectDir / empty relativePath) is emitted -// with an empty absolutePath, which detectParent never matches. `projectDir` is the -// current .rpp parent (the shell resolves it; empty -> all refs unresolved). +// Enumerates the active book's samples across every bank as pure BankFileRefs, +// each id paired with its file resolved to an absolute path against `projectDir`. +// An unresolvable path (empty projectDir/relativePath) gets an empty absolutePath, +// which detectParent never matches. std::vector bankFileRefs(const BankBook& book, const std::string& projectDir); -// Resolves a canonical track-GUID string (guidString form) to a live MediaTrack* -// in the active project by scanning tracks and comparing guidString(tr). Returns -// nullptr when no live track carries that GUID (the source track was deleted since -// capture — a re-capture failure mode the caller reports). The master track is not -// scanned (it has no membership GUID and is never a capture source). +// Resolves a canonical track-GUID string to a live MediaTrack* in the active +// project. Returns nullptr when no live track carries that GUID (the source track +// was deleted since capture — a re-capture failure mode the caller reports). The +// master track is not scanned (no membership GUID, never a capture source). MediaTrack* trackByGuid(const std::string& guid); } // namespace reasampler diff --git a/src/shell/capture/realtime_lifecycle.cpp b/src/shell/capture/realtime_lifecycle.cpp index 44f7f2f..7178bb7 100644 --- a/src/shell/capture/realtime_lifecycle.cpp +++ b/src/shell/capture/realtime_lifecycle.cpp @@ -1,10 +1,9 @@ -// realtime_lifecycle.cpp — the in-flight realtime-capture state machine + globals -// (Q-W3 hoist out of main.cpp; the code moved verbatim, the session threaded as a -// parameter). See the header. +// realtime_lifecycle.cpp — the in-flight realtime-capture state machine + globals. +// See the header. // -// Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.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; here they are extern (CLAUDE.md §contract). +// pointers; here they are extern. #include "shell/capture/realtime_lifecycle.h" @@ -17,15 +16,13 @@ namespace reasampler::capture { -// --- M8 in-flight realtime capture (async, timer-driven) -------------------- RealtimeRecordBackend g_rtBackend; RealtimeCaptureHandle g_rtCapture; ReaProject* g_rtCaptureProject = nullptr; -// Commit a finished realtime capture (a Done tick/abort with an Ok result): add the -// Sample to the ACTIVE bank (session.bank() resolves to book.activeIndex() — B2), -// persist + MarkProjectDirty. Shared by the tick-completion path and the abort -// paths. On a non-Ok result, logs the failure only. +// Commits a finished realtime capture (a Done tick/abort with an Ok result): adds +// the Sample to the active bank, persists + MarkProjectDirty. Shared by the +// tick-completion and abort paths. On a non-Ok result, logs the failure only. void CommitRealtimeResult(ReaSamplerSession& session, const CaptureResult& res) { if (res.status != CaptureStatus::Ok) @@ -34,40 +31,36 @@ void CommitRealtimeResult(ReaSamplerSession& session, const CaptureResult& res) return; } session.bank().add(res.sample); - // B-cap: record the file the capture created in the owned-file manifest, at the same - // point the Sample is added and before the same persist. Recorded regardless of the - // index AddResult — even a hash-collapse still WROTE a file the tool owns, and the - // manifest dedups a repeat path itself (Phase R prune reconciles manifest vs index). + // Record the file in the owned manifest regardless of the index AddResult — even + // a hash-collapse still wrote a file the tool owns; the manifest dedups a repeat + // path itself (prune reconciles manifest vs index). session.owned().add(res.sample.relativePath); - // S9: a capture add changes what a live instance could play (a new sample landed in the - // active bank) -> bump before the persist so the stamped generation refreshes instances. + // A capture add changes what a live instance could play, so bump the generation + // before persisting to refresh instances. session.bumpBankGeneration(); - session.saveToActiveProject(); // persist book + manifest + generation + MarkProjectDirty (travels with .rpp) + session.saveToActiveProject(); // persist book + manifest + generation + MarkProjectDirty } -// Advance any in-flight realtime capture one tick. Cheap when none is running (a -// null check) and fast even mid-record (tick() only reads the transport until the -// terminal tick). Detects a project switch mid-capture and aborts+restores so the -// capture never leaks across projects. Called from OnTimer BEFORE session.poll() so -// poll's project-switch handling sees a cleaned-up project. +// Advances any in-flight realtime capture one tick. Detects a project switch +// mid-capture and aborts+restores so the capture never leaks across projects. +// Called from OnTimer before session.poll() so poll's own project-switch handling +// sees an already-cleaned-up project. void DriveRealtimeCapture(ReaSamplerSession& session) { if (!g_rtCapture) return; - // Project switch guard: if the active project is no longer the one the capture - // belongs to, a new/other project became active mid-record — abort + restore - // (into the ORIGINAL project the state is bound to) and drop it. Do NOT finalize - // into the new project. + // If the active project is no longer the one the capture belongs to, a project + // switch happened mid-record: abort + restore into the original project the + // state is bound to, and drop it — never finalize into the new project. ReaProject* active = EnumProjects(-1, nullptr, 0); if (active != g_rtCaptureProject) { RealtimeTickResult r = g_rtBackend.abort(*g_rtCapture); - // Only commit if the ORIGINAL project is still open and active would be it — - // on a switch we restored into the original but must not persist into the - // now-active foreign project. Log the outcome without persisting. On a Failed - // abort surface abort()'s own message — it distinguishes a clean tab-switch - // abort from the closed-project DROP (the captured project was closed mid-record, - // review §1: nothing restored because the pointers were already freed). + // Log without persisting — we restored into the original project but must + // not persist into the now-active foreign one. A Failed abort surfaces + // abort()'s own message, distinguishing a clean tab-switch abort from the + // closed-project case (nothing restored because the pointers were already + // freed). if (r.status == RealtimeTickStatus::Done) ShowConsoleMsg("ReaSampler realtime capture: project switched mid-record -- " "captured audio restored into the original project; not " diff --git a/src/shell/capture/realtime_lifecycle.h b/src/shell/capture/realtime_lifecycle.h index d76e58f..89beb32 100644 --- a/src/shell/capture/realtime_lifecycle.h +++ b/src/shell/capture/realtime_lifecycle.h @@ -1,20 +1,17 @@ #pragma once -// realtime_lifecycle — the in-flight realtime-capture state machine + globals -// (Q-W3 hoist out of main.cpp). A realtime record spans many timer ticks (it takes -// end-start wall-clock seconds and must NOT block REAPER's UI): the action STARTS -// it (capture_orchestrator::RunCaptureRealtimeTrack -> g_rtBackend.begin), OnTimer -// drives it here (DriveRealtimeCapture -> g_rtBackend.tick) each tick until a +// The in-flight realtime-capture state machine + globals. A realtime record spans +// many timer ticks (it takes end-start wall-clock seconds and must not block +// REAPER's UI): the action starts it (RunCaptureRealtimeTrack -> g_rtBackend.begin), +// OnTimer drives it here (DriveRealtimeCapture -> g_rtBackend.tick) each tick until a // terminal verdict, then the handle is cleared. // -// The three globals are EXPOSED (extern) rather than wrapped: the action bodies in -// capture_orchestrator manipulate them exactly as main.cpp did (zero-behavior-change -// move), and — load-bearing (CONTEXT.md §Phase Q hot-path guardrail) — the timer's -// IDLE FAST-PATH stays a SINGLE POINTER TEST at the call site: +// The three globals are extern rather than wrapped so the timer's idle fast path +// stays a single pointer test at the call site — load-bearing: // if (capture::g_rtCapture) capture::DriveRealtimeCapture(g_session); // No per-tick cross-TU call, no accessor indirection, when nothing is recording. // -// REAPER-facing: the .cpp includes reaper_plugin_functions.h WITHOUT -// REAPERAPI_IMPLEMENT (main.cpp owns the API pointers — CLAUDE.md §contract). +// The .cpp includes reaper_plugin_functions.h WITHOUT REAPERAPI_IMPLEMENT +// (main.cpp owns the API pointers). #include "shell/capture/capture_realtime_shell.h" // RealtimeRecordBackend / RealtimeCaptureHandle @@ -24,33 +21,30 @@ class ReaSamplerSession; namespace reasampler::capture { -// The realtime backend + the in-flight capture handle. Non-null handle == a -// capture is in progress (used to reject a second one, to drive the per-tick -// advance, and to abort on project switch / unload). +// Non-null g_rtCapture == a capture is in progress: used to reject a second one, +// drive the per-tick advance, and abort on project switch / unload. extern RealtimeRecordBackend g_rtBackend; extern RealtimeCaptureHandle g_rtCapture; -// The ReaProject* the in-flight capture belongs to (opaque, compare-only) — lets +// The project the in-flight capture belongs to (opaque, compare-only) — lets // OnTimer detect a project switch mid-capture and abort+restore rather than leak the -// temp track/arm/transport into or across projects. Only meaningful when -// g_rtCapture != nullptr. +// temp track/arm/transport across projects. Meaningful only when g_rtCapture != nullptr. extern ReaProject* g_rtCaptureProject; -// Commit a finished realtime capture (a Done tick/abort with an Ok result): add the -// Sample to the ACTIVE bank, record the owned file, bump the generation, persist + -// MarkProjectDirty. On a non-Ok result, logs the failure only. +// Commits a finished realtime capture (a Done tick/abort with an Ok result): adds +// the Sample to the active bank, records the owned file, bumps the generation, +// persists + MarkProjectDirty. On a non-Ok result, logs the failure only. void CommitRealtimeResult(ReaSamplerSession& session, const CaptureResult& res); -// Advance any in-flight realtime capture one tick. Cheap when none is running (a -// null check — though the caller already guards, see the header note) and fast even -// mid-record. Detects a project switch mid-capture and aborts+restores so the -// capture never leaks across projects. Called from OnTimer BEFORE session.poll(). +// Advances any in-flight realtime capture one tick. Detects a project switch +// mid-capture and aborts+restores so the capture never leaks across projects. +// Called from OnTimer before session.poll(). void DriveRealtimeCapture(ReaSamplerSession& session); // Unload teardown: abort any in-flight capture while the API pointers are still // live — finalize-or-abort + restore so we never leave a temp track, an armed -// track, or an altered transport/cursor in the user's project on unload. Commits -// whatever was captured (best effort) before tearing down. No-op when idle. +// track, or an altered transport/cursor behind. Commits whatever was captured +// (best effort) before tearing down. No-op when idle. void AbortRealtimeCaptureForUnload(ReaSamplerSession& session); } // namespace reasampler::capture diff --git a/src/shell/capture/scope_resolve.cpp b/src/shell/capture/scope_resolve.cpp index 6caabe5..adbaa1c 100644 --- a/src/shell/capture/scope_resolve.cpp +++ b/src/shell/capture/scope_resolve.cpp @@ -1,10 +1,9 @@ -// scope_resolve.cpp — scope/source resolution for the capture action family -// (Q-W3 hoist out of main.cpp; the code moved verbatim, session state threaded as -// parameters). See the header. +// scope_resolve.cpp — scope/source resolution for the capture action family. See +// the header. // -// Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.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; here they are extern (CLAUDE.md §contract). +// pointers; here they are extern. #include "shell/capture/scope_resolve.h" @@ -50,8 +49,7 @@ model::ProvenanceScope provenanceScopeFor(CaptureScope scope) // Collects the tracks that own the selected items (Item scope) into // out.sourceTracks (deduped) — these are the tracks whose FX must be bypassed so an -// item capture hears take/item FX only. GetMediaItem_Track(item) gives the owning -// track (SDK header, verify). GUIDs recorded for provenance. +// item capture hears take/item FX only. GUIDs recorded for provenance. bool collectSelectedItemTracks(ResolvedSource& out) { const int n = CountSelectedMediaItems(nullptr); @@ -76,9 +74,8 @@ bool collectSelectedItemTracks(ResolvedSource& out) } // namespace // Reads every track's P_RAZOREDITS (SDK header ~2899: space-separated triples of -// start, end, envGuidString), parses the track-audio areas (pure parseRazorEdits), -// and returns the union bound. Reads only — never clears the razor selection. -// Returns false when no track-audio razor area exists on any track. +// start, end, envGuidString) and returns the union of parsed track-audio areas. +// Reads only — never clears the razor selection. bool resolveRazorRange(double& start, double& end) { std::vector allRanges; @@ -100,9 +97,6 @@ bool resolveRazorRange(double& start, double& end) return end > start; } -// Infers the render RANGE for any scope: razor union when a razor area is present, -// else the time selection (pure inferRangeSource decides which). Orthogonal to -// scope. Returns false (with a reason) when neither yields a non-empty range. bool resolveRange(double& start, double& end, std::string& why) { double rzStart = 0.0, rzEnd = 0.0; @@ -117,7 +111,6 @@ bool resolveRange(double& start, double& end, std::string& why) return false; } -// Collects the selected tracks (Track scope) into out.sourceTracks + GUIDs. bool collectSelectedTracks(ResolvedSource& out) { const int n = CountSelectedTracks(nullptr); // nullptr = active project @@ -133,8 +126,6 @@ bool collectSelectedTracks(ResolvedSource& out) return !out.sourceTracks.empty(); } -// Resolves the source for a scope: the selection tracks (item/track), plus the -// inferred range. Returns false with a reason on nothing to do. bool ResolveScopeSource(CaptureScope scope, ResolvedSource& out, std::string& why) { switch (scope) @@ -153,11 +144,8 @@ bool ResolveScopeSource(CaptureScope scope, ResolvedSource& out, std::string& wh return resolveRange(out.startSeconds, out.endSeconds, why); } -// Current project's directory (parent of its .rpp), forward-slashed, no trailing -// slash — the same derivation capture.cpp does internally, needed here so M10 can -// resolve the bank's relative paths to absolute for parent detection. Empty for an -// unsaved project (EnumProjects writes an empty .rpp path), which makes every bank -// file resolve empty -> no false parentage. Read-only; mutates nothing. +// Empty for an unsaved project (EnumProjects writes an empty .rpp path), which +// makes every bank file resolve empty -> no false parentage. std::string currentProjectDir() { std::vector buf(4096, '\0'); @@ -171,16 +159,8 @@ std::string currentProjectDir() return dir; } -// Builds the M10 provenance for a capture IF it genuinely resamples from a bank -// sample, else returns nullopt (the common, non-resample case). Detection rule -// (stated honestly): the capture's source item media file(s) must all resolve, by -// exact normalized absolute path, to ONE bank sample's file (detectParent). On a -// match, records that sample's id as the parent plus a THIN capture-recipe -// fingerprint (P1=a) — scope + source mode + exact range + tail + rate + channels + -// source track GUIDs + the in-scope source FX-chain identity — so "re-capture from -// source" can replay the request and report drift. NEVER a serialized chain to -// restore. Item scope reads the active take's TakeFX chain (via TakeFX_*) per -// selected item, combined in item order; Track scope reads the track FX chain. +// Detection rule: the capture's source item media file(s) must all resolve, by +// exact normalized absolute path, to one bank sample's file. std::optional buildCaptureProvenance( const BankBook& book, const CaptureRequest& req, CaptureScope scope, const ResolvedSource& src) @@ -188,9 +168,9 @@ std::optional buildCaptureProvenance( const std::string projectDir = currentProjectDir(); const std::vector bankFiles = bankFileRefs(book, projectDir); - // The "what audio is being captured" source set depends on scope: item scope uses - // the SELECTED items (the user picked them); track scope uses the range-overlapping - // items ON the source tracks (the user picked the track, not the item). + // What "the source audio" means depends on scope: item scope uses the selected + // items (the user picked them); track scope uses the range-overlapping items on + // the source tracks (the user picked the track, not the item). const std::vector sourceFiles = scope == CaptureScope::Item ? selectedItemSourceFiles() diff --git a/src/shell/capture/scope_resolve.h b/src/shell/capture/scope_resolve.h index 991b614..86d2ef3 100644 --- a/src/shell/capture/scope_resolve.h +++ b/src/shell/capture/scope_resolve.h @@ -1,18 +1,15 @@ #pragma once -// scope_resolve — scope/source resolution for the capture action family (Q-W3 -// hoist out of main.cpp). The three concerns every capture entry point shares: -// * RANGE inference — razor union else time selection (razor-else-time), -// orthogonal to scope; -// * SOURCE-TRACK collection — the selected tracks (Track scope) or the selected -// items' owning tracks (Item scope), deduped, with canonical GUIDs; -// * PROVENANCE ASSEMBLY inputs — the M10 resample-from-sample detection + the -// thin capture-recipe fingerprint built from the LIVE (un-bypassed) chain. +// Scope/source resolution shared by every capture entry point. Three concerns: +// * range inference — razor union else time selection, orthogonal to scope; +// * source-track collection — selected tracks (Track scope) or selected items' +// owning tracks (Item scope), deduped, with canonical GUIDs; +// * provenance-assembly inputs — resample-from-sample detection + the thin +// capture-recipe fingerprint built from the live (un-bypassed) chain. // // All reads are non-destructive: selection, razor, and time selection are read, -// never mutated. REAPER-facing: the .cpp includes reaper_plugin_functions.h -// WITHOUT REAPERAPI_IMPLEMENT (main.cpp owns the API pointers — CLAUDE.md -// §contract). MediaTrack is forward-declared (via capture.h) so this header stays -// SDK-lite. +// never mutated. The .cpp includes reaper_plugin_functions.h WITHOUT +// REAPERAPI_IMPLEMENT (main.cpp owns the API pointers). MediaTrack is forward- +// declared (via capture.h) so this header stays SDK-lite. #include #include @@ -35,18 +32,16 @@ struct ResolvedSource { double startSeconds = 0.0; double endSeconds = 0.0; - std::vector sourceTracks; // item-owning tracks / selected tracks - std::vector trackGuids; // canonical GUIDs of sourceTracks + std::vector sourceTracks; + std::vector trackGuids; }; -// Reads every track's P_RAZOREDITS, parses the track-audio areas (pure -// parseRazorEdits), and returns the union bound. Reads only — never clears the -// razor selection. Returns false when no track-audio razor area exists on any track. +// Reads every track's P_RAZOREDITS and returns the union of parsed track-audio +// areas. Reads only — never clears the razor selection. bool resolveRazorRange(double& start, double& end); -// Infers the render RANGE for any scope: razor union when a razor area is present, -// else the time selection (pure inferRangeSource decides which). Orthogonal to -// scope. Returns false (with a reason) when neither yields a non-empty range. +// Infers the render range for any scope: razor union when present, else the time +// selection. Returns false with a reason when neither yields a non-empty range. bool resolveRange(double& start, double& end, std::string& why); // Collects the selected tracks (Track scope) into out.sourceTracks + GUIDs. @@ -60,10 +55,10 @@ bool ResolveScopeSource(CaptureScope scope, ResolvedSource& out, std::string& wh // slash. Empty for an unsaved project (no false parentage). Read-only. std::string currentProjectDir(); -// Builds the M10 provenance for a capture IF it genuinely resamples from a bank -// sample (detectParent over `book`'s resolved file refs), else returns nullopt (the -// common, non-resample case). Must run BEFORE the FxBypassGuard neutralizes the -// in-scope chain — the source FX-chain identity is read from the LIVE chain. +// Builds the provenance for a capture if it genuinely resamples from a bank sample +// (detectParent over `book`'s resolved file refs), else returns nullopt (the common, +// non-resample case). Must run BEFORE the FxBypassGuard neutralizes the in-scope +// chain — the source FX-chain identity is read from the live chain. std::optional buildCaptureProvenance( const BankBook& book, const CaptureRequest& req, CaptureScope scope, const ResolvedSource& src); diff --git a/src/shell/capture/track_guid.cpp b/src/shell/capture/track_guid.cpp index 2588952..a4f7abc 100644 --- a/src/shell/capture/track_guid.cpp +++ b/src/shell/capture/track_guid.cpp @@ -1,7 +1,7 @@ // track_guid.cpp — the single MediaTrack* -> canonical GUID key formatter. See -// track_guid.h. Compiled into the reaper_reasampler MODULE; includes +// track_guid.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). +// that defines the API pointers). #include "shell/capture/track_guid.h" diff --git a/src/shell/capture/track_guid.h b/src/shell/capture/track_guid.h index 64f2026..7ff1fbc 100644 --- a/src/shell/capture/track_guid.h +++ b/src/shell/capture/track_guid.h @@ -1,13 +1,12 @@ #pragma once -// track_guid — the ONE place a MediaTrack* is formatted into the canonical GUID -// string used as a membership-index key. Both the Design View shell (view.cpp) and -// the actions layer (design_view_actions.cpp) key membership on this exact string, so the key -// contract lives in a single helper rather than being re-derived (and drifting) at -// two call sites (the cross-module key contract flagged in D2 review). +// The one place a MediaTrack* is formatted into the canonical GUID string used as +// a membership-index key. Both the Design View shell (view.cpp) and the actions +// layer (design_view_actions.cpp) key membership on this exact string, so the +// contract lives in a single helper rather than being re-derived at two call sites. // -// REAPER-facing: the .cpp includes reaper_plugin_functions.h WITHOUT -// REAPERAPI_IMPLEMENT (main.cpp owns the API pointers; here they are extern — -// CLAUDE.md §contract). MediaTrack is forward-declared so this header stays SDK-lite. +// The .cpp includes reaper_plugin_functions.h WITHOUT REAPERAPI_IMPLEMENT +// (main.cpp owns the API pointers). MediaTrack is forward-declared so this header +// stays SDK-lite. #include diff --git a/src/shell/instrument/editor_controls.cpp b/src/shell/instrument/editor_controls.cpp index 7852597..a23d2a7 100644 --- a/src/shell/instrument/editor_controls.cpp +++ b/src/shell/instrument/editor_controls.cpp @@ -1,8 +1,7 @@ -// editor_controls.cpp — the ReaSamplerEditor's PARAMETER PLUMBING (Q-W2v split of -// reasampler_editor.cpp, T4-11): the control-value domain maps (controlValue / -// applyControl — seconds/fraction/frames <-> normalized 0..1), the r11 knob-deck -// group descriptors + control-id<->value binding, the S-VIEW-3 envelope pack/unpack -// (the TRIGGER SEAM converter), the curve-popup target resolution, and applyZoneControl. +// editor_controls.cpp — the ReaSamplerEditor's parameter plumbing: the control-value domain +// maps (controlValue / applyControl — seconds/fraction/frames <-> normalized 0..1), the +// knob-deck group descriptors + control-id<->value binding, the envelope pack/unpack +// (the trigger-seam converter), the curve-popup target resolution, and applyZoneControl. // Value logic only — no painting, no window plumbing. #include "shell/instrument/reasampler_editor.h" @@ -13,8 +12,8 @@ #include #include -#include "core/instrument/engine/master_gain.h" // r11 master-gain dB<->linear<->knob taper (FB1) -#include "core/instrument/map/trigger_seam.h" // triggerPlayLength / fade fraction converters (S-VIEW-3) +#include "core/instrument/engine/master_gain.h" // master-gain dB<->linear<->knob taper +#include "core/instrument/map/trigger_seam.h" // triggerPlayLength / fade fraction converters #include "core/util/clamp01.h" #include "shell/instrument/editor_internal.h" // DeckGroup ids #include "shell/instrument/reasampler_processor.h" @@ -22,35 +21,32 @@ namespace reasampler::vst { using namespace reasampler::instrument::map; // ZonePlaySeconds vocabulary + trigger_seam converters -using instrument::ui::EnvMode; // envelope_overlay's mode enum (Q-W6: shim retired) +using instrument::ui::EnvMode; // envelope_overlay's mode enum using instrument::engine::formatMasterGainLabel; using instrument::engine::masterGainLinearFromNorm; using instrument::engine::masterGainNormFromLinear; using util::clamp01; namespace { -// The S12/S15/S16 control-surface value DOMAINS (the shell owns these — param_slider is -// engine-free and maps only 0..1). WALL-CLOCK time sliders (AHDSR A/H/D/R, pitch env A/D) span -// [0, kEnvTimeMaxSeconds] SECONDS — rate-free, exactly what the zone stores; the keymap build -// resolves seconds->frames at the live rate. SOURCE-timeline fade sliders (Trigger fade-in/out) -// STORE source frames (PLAN.md §S15 — never a wall-clock second; the storage domain is -// settled-correct and unchanged), but the knob's FULL-SCALE THROW is a wall-clock intent — -// kFadeMaxSeconds resolved against the live rate at use (fadeMaxFrames(), Q-W0 T3-03; the -// prior 88200-frame constant baked 2 s x 44.1 kHz into src/, against the no-hardcoded-rate -// ruling). Build-time residual — one place to retune; not persisted. +// Control-surface value domains (the shell owns these — param_slider is engine-free and maps +// only 0..1). Wall-clock time sliders (AHDSR A/H/D/R, pitch env A/D) span [0, kEnvTimeMaxSeconds] +// seconds — rate-free, exactly what the zone stores; the keymap build resolves seconds->frames +// at the live rate. Source-timeline fade sliders (Trigger fade-in/out) store source frames +// (never a wall-clock second), but the knob's full-scale throw is a wall-clock intent — +// kFadeMaxSeconds resolved against the live rate at use (fadeMaxFrames()) rather than a baked-in +// rate constant, per the no-hardcoded-rate ruling. constexpr double kEnvTimeMaxSeconds = 2.0; // AHDSR A/H/D/R + pitch A/D throw ceiling (seconds) constexpr double kFadeMaxSeconds = 2.0; // Trigger fade throw ceiling (wall-clock) constexpr double kPitchDepthMaxSemis = 24.0; // AD pitch depth throw: +/-24 st, centered -constexpr double kKeyTrackMax = 2.0; // S-VIEW-6 key-track slider ceiling (0..200%) +constexpr double kKeyTrackMax = 2.0; // key-track slider ceiling (0..200%) } // namespace double ReaSamplerEditor::controlValue(int id, const ZonePlaySeconds& play) const { // Wall-clock seconds -> normalized over the seconds ceiling; source frames -> normalized over - // the rate-resolved frames ceiling (T3-03). Two domains, kept explicit so neither leaks a rate. - // A stored fade exceeding fadeMaxFrames() at the current host rate reads as norm 1.0 (clamp01 - // pins it) and gets rewritten down on the next knob touch — deliberate, matching the old - // fixed-ceiling clamp behavior in kind, just rate-dependent now instead of fixed at 88200. + // the rate-resolved frames ceiling. Two domains, kept explicit so neither leaks a rate. A + // stored fade exceeding fadeMaxFrames() at the current host rate reads as norm 1.0 (clamp01 + // pins it) and gets rewritten down on the next knob touch. const double fadeMax = fadeMaxFrames(); const auto secToNorm = [](double s) { return clamp01(s / kEnvTimeMaxSeconds); }; const auto framesToNorm = [fadeMax](std::int64_t f) { @@ -80,7 +76,7 @@ double ReaSamplerEditor::controlValue(int id, const ZonePlaySeconds& play) const void ReaSamplerEditor::applyControl(int id, ZonePlaySeconds& play, double value, int segment) const { - const double fadeMax = fadeMaxFrames(); // T3-03: rate-resolved knob full-scale + const double fadeMax = fadeMaxFrames(); // rate-resolved knob full-scale const auto normToSec = [](double v) { return clamp01(v) * kEnvTimeMaxSeconds; }; const auto normToFrames = [fadeMax](double v) -> std::int64_t { // Ceiling unavailable (rate not yet known): inert until fadeMaxFrames() resolves. @@ -122,14 +118,12 @@ double ReaSamplerEditor::liveSampleRate() const { } double ReaSamplerEditor::fadeMaxFrames() const { - // T3-03: the Trigger-fade knob's full-scale throw is kFadeMaxSeconds (2 s wall-clock) - // resolved against the live rate — the SAME time base the envelope overlay already uses - // to place these source-frame fades on screen (totalSeconds = frames / liveSampleRate()), - // and the rate captures are made at (the capture path renders at the project rate). - // Pre-setupProcessing the rate is still 0: rather than substitute a literal rate (the - // exact residue T3-03 removed), bail the same way paintEnvelopeOverlay does (~line 1396) — - // callers treat a <= 0 return as "ceiling unavailable yet" and degrade the knob to inert - // rather than guess a rate. Storage stays SOURCE FRAMES — this resolves the UI ceiling only. + // The Trigger-fade knob's full-scale throw is kFadeMaxSeconds (2 s wall-clock) resolved + // against the live rate — the same time base the envelope overlay already uses to place + // these source-frame fades on screen. Pre-setupProcessing the rate is still 0: rather than + // substitute a literal rate, callers treat a <= 0 return as "ceiling unavailable yet" and + // degrade the knob to inert rather than guess a rate. Storage stays source frames — this + // resolves the UI ceiling only. const double rate = liveSampleRate(); if (rate <= 0.0) return 0.0; return kFadeMaxSeconds * rate; @@ -141,11 +135,11 @@ double ReaSamplerEditor::previewVelocity01() const { } std::vector ReaSamplerEditor::zoneDeckGroupDescs(const ZonePlaySeconds& play) const { - // The PER-ZONE groups — the deck grammar both surfaces share (FB2: the Zone panel renders - // exactly these; the Sample face appends the per-instance groups in deckGroupDescs). - // Group widths are MODE-INDEPENDENT: AMP ENVELOPE reserves its 5-cell Gate width (Trigger - // leaves two blank cells), so a Gate<->Trigger flip repopulates in place and never reflows - // the neighbouring groups (r11). + // The per-zone groups — the deck grammar both surfaces share (the Zone panel renders + // exactly these; the Sample face appends the per-instance groups in deckGroupDescs). Group + // widths are mode-independent: AMP ENVELOPE reserves its 5-cell Gate width (Trigger leaves + // two blank cells), so a Gate<->Trigger flip repopulates in place and never reflows the + // neighbouring groups. std::vector out; { DeckGroupDesc amp; @@ -159,8 +153,8 @@ std::vector ReaSamplerEditor::zoneDeckGroupDescs(const ZonePlaySe static_cast(ParamControl::kSustain), static_cast(ParamControl::kRelease)}; } else { - // Trigger, TIME-ORDERED left-to-right (r11: Fade In · Length % · Fade Out — - // matches the drawn envelope), plus the two reserved blanks. + // Trigger, time-ordered left-to-right (Fade In / Length % / Fade Out — matches + // the drawn envelope), plus the two reserved blanks. amp.cellIds = {static_cast(ParamControl::kTrigFadeIn), static_cast(ParamControl::kTrigLength), static_cast(ParamControl::kTrigFadeOut), -1, -1}; @@ -190,9 +184,8 @@ std::vector ReaSamplerEditor::zoneDeckGroupDescs(const ZonePlaySe std::vector ReaSamplerEditor::deckGroupDescs(const ZonePlaySeconds& play) const { // The full Sample-face deck: the shared per-zone groups + the per-instance VOICE + MASTER - // groups. VOICE + MASTER are the FB1 homes for the provisional voice-deck controls and the - // post-mixer gain — the r11 spec predates both; per-instance state (ComponentState) stays - // OFF the Zone panel (FB2), so they are appended here, not in zoneDeckGroupDescs. + // groups. Per-instance state (ComponentState) stays off the Zone panel, so they are + // appended here, not in zoneDeckGroupDescs. std::vector out = zoneDeckGroupDescs(play); { DeckGroupDesc voice; @@ -303,8 +296,8 @@ std::string ReaSamplerEditor::deckValueLabel(int id, const PerformanceZone& zone EnvClampBounds ReaSamplerEditor::envClampBounds() const { // Match the control-panel sliders' own domains so a node drag can never produce a param a - // slider couldn't (the S-VIEW-F2 invariant). AHDSR seconds cap at kEnvTimeMaxSeconds; the - // Trigger fade/length fractions cap at 1.0 (the natural full-span bound the sliders use). + // slider couldn't. AHDSR seconds cap at kEnvTimeMaxSeconds; the Trigger fade/length + // fractions cap at 1.0 (the natural full-span bound the sliders use). EnvClampBounds b; b.maxAttackSeconds = kEnvTimeMaxSeconds; b.maxHoldSeconds = kEnvTimeMaxSeconds; @@ -326,8 +319,8 @@ AmpEnvelope ReaSamplerEditor::packEnvelope(const ZonePlaySeconds& play, std::int env.decaySeconds = play.adsr.decaySeconds; env.sustainLevel = play.adsr.sustainLevel; env.releaseSeconds = play.adsr.releaseSeconds; - // Trigger: lengthFraction copies 1-to-1; the fades are DERIVED — source frames over the played - // span (the TRIGGER SEAM converter, PACK direction). startFrame is the zone's effective start + // Trigger: lengthFraction copies 1-to-1; the fades are derived — source frames over the played + // span (the trigger-seam converter, pack direction). startFrame is the zone's effective start // point so the fraction denominator matches the voice's actual post-start span. A zero play // length yields 0 fractions. env.lengthFraction = play.trigger.lengthFraction; @@ -348,10 +341,10 @@ void ReaSamplerEditor::unpackEnvelope(const AmpEnvelope& env, std::int64_t frame play.adsr.releaseSeconds = env.releaseSeconds; } else { // Trigger: lengthFraction copies back; the fades convert fractions -> source frames over - // the played span (the TRIGGER SEAM converter, UNPACK direction). startFrame is the zone's - // effective start point so the frame denominator matches the voice's actual post-start span. - // Keep the same (0,1] floor on lengthFraction the slider path enforces so a zero-length - // trigger never plays nothing. + // the played span (the trigger-seam converter, unpack direction). startFrame is the + // zone's effective start point so the frame denominator matches the voice's actual + // post-start span. Keep the same (0,1] floor on lengthFraction the slider path enforces + // so a zero-length trigger never plays nothing. play.trigger.lengthFraction = (std::max)(0.01, env.lengthFraction); const std::int64_t playLen = triggerPlayLength(play.trigger.lengthFraction, frames, startFrame); @@ -361,8 +354,8 @@ void ReaSamplerEditor::unpackEnvelope(const AmpEnvelope& env, std::int64_t frame } PerformanceZone ReaSamplerEditor::popupZone() const { - // The zone the popup displays: the Zone surface's SELECTED zone (FB2), else the Sample - // face's one-zone site (a read-only resolve — an edit materializes via popupZoneIndex). + // The zone the popup displays: the Zone surface's selected zone, else the Sample face's + // one-zone site (a read-only resolve — an edit materializes via popupZoneIndex). if (view_ == View::kZone && selectedZone_ >= 0 && selectedZone_ < static_cast(map_.zones.size())) { return map_.zones[static_cast(selectedZone_)]; diff --git a/src/shell/instrument/editor_input_browse_zone.cpp b/src/shell/instrument/editor_input_browse_zone.cpp index 1587c23..7ee41cd 100644 --- a/src/shell/instrument/editor_input_browse_zone.cpp +++ b/src/shell/instrument/editor_input_browse_zone.cpp @@ -1,10 +1,9 @@ -// editor_input_browse_zone.cpp — the ReaSamplerEditor's BROWSE-MODAL and ZONE-SURFACE -// input + the hover resolver (Q-W2v split of reasampler_editor.cpp, T4-11): the L3 hover -// resolution across all three faces, the Browse picker's click branch (tabs, cards, -// select-then-confirm, scroll-thumb grab, search focus), the Zone surface's click branch -// (add/delete, strip drags, numeric-entry focus, per-zone deck + curve button), the -// browser wheel scroll, the type-to-filter / note-entry keystrokes, and the S13 degraded -// drop affordance. Windows-only (D5). +// editor_input_browse_zone.cpp — the ReaSamplerEditor's browse-modal and zone-surface +// input + the hover resolver: hover resolution across all three faces, the Browse picker's +// click branch (tabs, cards, select-then-confirm, scroll-thumb grab, search focus), the +// Zone surface's click branch (add/delete, strip drags, numeric-entry focus, per-zone deck +// + curve button), the browser wheel scroll, the type-to-filter / note-entry keystrokes, +// and the degraded drop affordance. Windows-only. #include "shell/instrument/reasampler_editor.h" @@ -15,10 +14,10 @@ #include #include -#include "core/instrument/ui/browser_scroll.h" // BrowseModal + scroll/search geometry (S12) +#include "core/instrument/ui/browser_scroll.h" // BrowseModal + scroll/search geometry #include "core/instrument/ui/curve_popup.h" // computeCurvePopup (popup hover) #include "core/instrument/ui/knob_deck.h" // hitTestDeck / kDeckKnobSize -#include "core/instrument/map/note_entry.h" // parseNoteEntry (S12 numeric entry) +#include "core/instrument/map/note_entry.h" // parseNoteEntry (numeric entry) #include "shell/instrument/editor_internal.h" // curveBoxFromRect (popup node hover) #include "shell/instrument/reasampler_processor.h" @@ -28,8 +27,6 @@ using namespace reasampler::ui; using namespace reasampler::instrument::ui; using namespace reasampler::instrument::map; -// --- Hover resolution (Phase L, L3) ------------------------------------------ -// // Resolve the interactive element under (x, y) into hover_ and repaint only on change (an // idle move is free). Mirrors onMouseDown's hit-test order, but read-only. Windows-only. void ReaSamplerEditor::resolveHover(int x, int y) { @@ -57,7 +54,7 @@ void ReaSamplerEditor::resolveHover(int x, int y) { if (tab >= 0) h = {HoverKind::kFilterTab, tab}; else if (card >= 0) h = {HoverKind::kCard, card}; } - } else if (curvePopupOpen_) { // the r11 curve popup — modal over Sample AND Zone (FB2) + } else if (curvePopupOpen_) { // the curve popup — modal over Sample and Zone const CurvePopupLayout pl = computeCurvePopup(w, hgt); if (contains(pl.close, x, y)) { h = {HoverKind::kPopupClose, -1}; @@ -79,8 +76,8 @@ void ReaSamplerEditor::resolveHover(int x, int y) { } else if (selectedZone_ >= 0 && contains(delR, x, y)) { h = {HoverKind::kDeleteZone, -1}; } else if (selectedZone_ >= 0 && selectedZone_ < static_cast(map_.zones.size())) { - // FB2: the per-zone knob deck + the mini curve-preview button (the Sample deck's - // hover grammar — knobs light + swap label->value). + // The per-zone knob deck + the mini curve-preview button (the Sample deck's hover + // grammar — knobs light + swap label->value). if (contains(zonesCurveButton(content), x, y)) { h = {HoverKind::kCurveButton, -1}; } else { @@ -93,7 +90,7 @@ void ReaSamplerEditor::resolveHover(int x, int y) { if (dh.kind != DeckHitKind::None) h = {HoverKind::kControl, dh.id}; } } - } else { // Sample view (home, r11 recomposition) + } else { // Sample view (home) const PerformanceZone zone = effectiveSampleZone(); const std::vector descs = deckGroupDescs(zone.play); const SampleBands bands = @@ -128,8 +125,8 @@ void ReaSamplerEditor::resolveHover(int x, int y) { } } -// The Browse-modal branch of the mouse-down dispatch (formerly inline in onMouseDown — -// behavior-identical; see editor_input_sample.cpp for the dispatch). +// The Browse-modal branch of the mouse-down dispatch (see editor_input_sample.cpp for the +// dispatch). void ReaSamplerEditor::mouseDownBrowse(int w, int h, int x, int y) { const BrowseModal bm = computeBrowseModal(w, h); if (contains(bm.back, x, y) || contains(bm.cancel, x, y)) { @@ -198,8 +195,8 @@ void ReaSamplerEditor::mouseDownBrowse(int w, int h, int x, int y) { return; } -// The Zone-surface branch of the mouse-down dispatch (formerly the tail of onMouseDown — -// behavior-identical; the curve popup is modal over the Zone surface too, FB2). +// The Zone-surface branch of the mouse-down dispatch (the curve popup is modal over the +// Zone surface too). void ReaSamplerEditor::mouseDownZone(int w, int h, int x, int y) { if (handlePopupMouseDown(w, h, x, y)) return; const Rect back = zoneBackRect(w, h); @@ -209,11 +206,11 @@ void ReaSamplerEditor::mouseDownZone(int w, int h, int x, int y) { if (contains(addR, x, y)) { // Add a narrow default zone for the picked capture (or the first visible sample as a // sensible seed). No pick -> nothing to add. If a full-keyboard zone for the seed id - // already exists (pre-fix bleed survivor), select it rather than appending a duplicate - // (mirrors the upsert the root-marker drag path already performs). - // NARROW DEFAULT: seed [root-6, root+5] (one octave centred on the bank root, clamped - // to [0,127]) so the new zone is immediately "authored" (narrow) and survives - // reconcileSingleCaptureZones without being treated as a Sample-face full-range zone. + // already exists, select it rather than appending a duplicate (mirrors the upsert the + // root-marker drag path already performs). Narrow default: seed [root-6, root+5] (one + // octave centred on the bank root, clamped to [0,127]) so the new zone is immediately + // "authored" (narrow) and survives reconcileSingleCaptureZones without being treated + // as a Sample-face full-range zone. std::string seed = !selectedId_.empty() ? selectedId_ : (!visible_.empty() ? visible_.front().id : std::string()); if (seed.empty()) return; @@ -290,7 +287,7 @@ void ReaSamplerEditor::mouseDownZone(int w, int h, int x, int y) { return; } - // S12 numeric-entry fields (low/high/root): a click focuses the field for typing. Only when a + // Numeric-entry fields (low/high/root): a click focuses the field for typing. Only when a // zone is selected. entryText_ starts empty (the user types the full value). if (selectedZone_ >= 0 && selectedZone_ < static_cast(map_.zones.size())) { const Rect fields = noteEntryFieldsArea(content); @@ -305,9 +302,9 @@ void ReaSamplerEditor::mouseDownZone(int w, int h, int x, int y) { } entryField_ = -1; // a click elsewhere in the Zone view cancels an in-progress entry - // The per-zone param surface (FB2): the knob deck + the mini curve-preview button — the - // SAME grammar and hit-test machinery as the Sample face. Only when a zone is selected - // (the Zone surface has no single-capture fallback — that lives on the Sample face). + // The per-zone param surface: the knob deck + the mini curve-preview button — the same + // grammar and hit-test machinery as the Sample face. Only when a zone is selected (the + // Zone surface has no single-capture fallback — that lives on the Sample face). if (selectedZone_ >= 0 && selectedZone_ < static_cast(map_.zones.size())) { if (contains(zonesCurveButton(content), x, y)) { curvePopupOpen_ = true; @@ -335,7 +332,7 @@ void ReaSamplerEditor::mouseDownZone(int w, int h, int x, int y) { hit.id == static_cast(ParamControl::kPitchEnvDecay) || hit.id == static_cast(ParamControl::kPitchEnvDepth); if (pitchEnvKnob && !play.pitchEnv.enabled) return; - // GRAB-ANCHORED vertical drag (FA4): live-drag the map, commit on release. + // Grab-anchored vertical drag: live-drag the map, commit on release. drag_ = DragKind::kDeckKnob; dragParamId_ = hit.id; dragParamZone_ = selectedZone_; @@ -351,8 +348,8 @@ void ReaSamplerEditor::mouseDownZone(int w, int h, int x, int y) { void ReaSamplerEditor::onMouseWheel(int delta) { // Browser scroll (only in the Browse modal — the sole card grid). One wheel notch - // (WHEEL_DELTA==120) scrolls roughly one card row; the offset is clamped at paint. A positive - // delta (wheel up) scrolls toward the top (smaller offset). + // (WHEEL_DELTA==120) scrolls roughly one card row; the offset is clamped at paint. A + // positive delta (wheel up) scrolls toward the top (smaller offset). if (view_ != View::kBrowse) return; const int rows = delta / 120; if (rows == 0) return; @@ -362,8 +359,8 @@ void ReaSamplerEditor::onMouseWheel(int delta) { } void ReaSamplerEditor::onSearchChar(unsigned int ch) { - // r11 curve popup: Esc dismisses (checked first — the popup is modal over the Sample face - // or the Zone surface, FB2; opening it clears any note-entry focus, and the Browse search + // The curve popup: Esc dismisses (checked first — the popup is modal over the Sample face + // or the Zone surface; opening it clears any note-entry focus, and the Browse search // cannot hold focus under it). if (curvePopupOpen_ && ch == 27) { curvePopupOpen_ = false; @@ -371,7 +368,7 @@ void ReaSamplerEditor::onSearchChar(unsigned int ch) { return; } - // S12 numeric note-entry (Zone surface): a focused low/high/root field accumulates keystrokes + // Numeric note-entry (Zone surface): a focused low/high/root field accumulates keystrokes // and commits via parseNoteEntry on Enter. Handled before the search box (a field, when // focused, owns the keystrokes). if (view_ == View::kZone && entryField_ >= 0) { @@ -402,8 +399,9 @@ void ReaSamplerEditor::onSearchChar(unsigned int ch) { return; } - // S12 type-to-filter search. Only when the search box has focus (a click focuses it). Backspace - // deletes; a printable ASCII char appends; the visible list recomposes (bank filter, then search). + // Type-to-filter search. Only when the search box has focus (a click focuses it). Backspace + // deletes; a printable ASCII char appends; the visible list recomposes (bank filter, then + // search). if (view_ != View::kBrowse || !searchFocused_) return; if (ch == 8) { // backspace if (!searchQuery_.empty()) searchQuery_.pop_back(); @@ -421,12 +419,12 @@ void ReaSamplerEditor::onSearchChar(unsigned int ch) { } void ReaSamplerEditor::onFilesDropped(int droppedCount) { - // S13 relay DEGRADED. The instrument is a read-only bank consumer and the cross-artifact - // ingest relay (editor drop -> extension) is not shipped (see the header note + the handoff - // decision point), so we do NOT ingest the dropped files and — load-bearing — NEVER insert a - // timeline item. Instead of silently swallowing the drop, flash a clear affordance pointing - // at the shipped ingest gesture. dropHintTicks_ counts sync ticks (kSyncTimerIntervalMs - // each); ~6 ticks keeps the banner up a few seconds, then onSyncTimer decays it to 0. + // The instrument is a read-only bank consumer and the cross-artifact ingest relay (editor + // drop -> extension) is not shipped, so we do not ingest the dropped files and — load- + // bearing — never insert a timeline item. Instead of silently swallowing the drop, flash a + // clear affordance pointing at the shipped ingest gesture. dropHintTicks_ counts sync ticks + // (kSyncTimerIntervalMs each); ~6 ticks keeps the banner up a few seconds, then onSyncTimer + // decays it to 0. (void)droppedCount; // count is informational; the banner text is drop-count-agnostic dropHintTicks_ = 6; #ifdef _WIN32 diff --git a/src/shell/instrument/editor_input_sample.cpp b/src/shell/instrument/editor_input_sample.cpp index b236d76..a973372 100644 --- a/src/shell/instrument/editor_input_sample.cpp +++ b/src/shell/instrument/editor_input_sample.cpp @@ -1,10 +1,10 @@ -// editor_input_sample.cpp — the ReaSamplerEditor's SAMPLE-FACE input + the drag-state -// machine (Q-W2v split of reasampler_editor.cpp, T4-11): the mouse-down dispatch (the -// Sample-face branch inline; Browse/Zone branches delegate to editor_input_browse_zone), -// the curve-popup/curve-box click machinery, the live drag resolution (onMouseMove — deck -// knobs, root marker, envelope nodes, curve nodes, wave markers, scroll thumb, zone -// edges), the release commit (onMouseUp), and the popup right-click delete. Windows-only -// (D5). All hit-test math is pure; this TU routes and mutates editor state only. +// editor_input_sample.cpp — the ReaSamplerEditor's sample-face input + the drag-state +// machine: the mouse-down dispatch (the Sample-face branch inline; Browse/Zone branches +// delegate to editor_input_browse_zone), the curve-popup/curve-box click machinery, the +// live drag resolution (onMouseMove — deck knobs, root marker, envelope nodes, curve +// nodes, wave markers, scroll thumb, zone edges), the release commit (onMouseUp), and the +// popup right-click delete. Windows-only. All hit-test math is pure; this TU routes and +// mutates editor state only. #include "shell/instrument/reasampler_editor.h" @@ -16,11 +16,11 @@ #include #include "core/instrument/ui/browser_scroll.h" // BrowseModal + thumbDragToOffset (scroll drag) -#include "core/instrument/ui/curve_popup.h" // computeCurvePopup / popupOutsideSheet (r11) -#include "core/instrument/ui/envelope_edit.h" // nodeAtPoint / resolveNodeDrag (S-VIEW-3) +#include "core/instrument/ui/curve_popup.h" // computeCurvePopup / popupOutsideSheet +#include "core/instrument/ui/envelope_edit.h" // nodeAtPoint / resolveNodeDrag #include "core/instrument/ui/knob_deck.h" // hitTestDeck / kDeckKnobSize -#include "core/instrument/ui/param_slider.h" // knobDragValue (FA4 grab-anchored drag) -#include "core/instrument/ui/waveform_view.h" // markerAtPoint / resolveDragFrame / snap (S11) +#include "core/instrument/ui/param_slider.h" // knobDragValue (grab-anchored drag) +#include "core/instrument/ui/waveform_view.h" // markerAtPoint / resolveDragFrame / snap #include "shell/instrument/editor_internal.h" // curveBoxFromRect + kCurveDragOffMargin #include "shell/instrument/reasampler_processor.h" @@ -31,11 +31,10 @@ using namespace reasampler::instrument::ui; using namespace reasampler::instrument::map; bool ReaSamplerEditor::handlePopupMouseDown(int w, int h, int x, int y) { - // The r11 curve popup: while open the sheet is MODAL over its host face — the Sample home - // (FB1) or the Zone surface (FB2) — it owns every left-click. Close click / outside-wash - // click dismiss (outside only when no drag is in flight, per the spec); in-box clicks - // route to the shared curve machinery against popupZoneIndex(); anything else on the - // sheet is swallowed. + // The curve popup: while open the sheet is modal over its host face — the Sample home or + // the Zone surface — it owns every left-click. Close click / outside-wash click dismiss + // (outside only when no drag is in flight); in-box clicks route to the shared curve + // machinery against popupZoneIndex(); anything else on the sheet is swallowed. if (!curvePopupOpen_) return false; const CurvePopupLayout pl = computeCurvePopup(w, h); if (contains(pl.close, x, y)) { @@ -77,13 +76,10 @@ void ReaSamplerEditor::handleCurveMouseDown(const Rect& r, int zoneIndex, int x, // ADD (mirror of the other map-editing drags' dragStartMap_ contract). dragStartMap_ = map_; - // Empty-space click inside the MAPPING BOX: add a control point at the cursor via the pure - // inverse map, then grab it — the click flows straight into a placing drag. Guard: the caller - // gates on contains(r, x, y) (the full border rect), but the 6+px inset ring — including the - // caption band — must not add a point; a click there would clamp to velocity 0/127 and - // produce an undeletable duplicate stacked on an endpoint. Clicks in the ring may still grab - // an existing node (pointAtPixel's pick radius legitimately extends into the ring), which is - // handled above; only the add path is box-gated here. + // Empty-space click inside the mapping box: add a control point via the pure inverse map, + // then grab it. Box-gated (not just contains(r,x,y)) because the inset ring must not add a + // point — it would clamp to velocity 0/127, stacking an undeletable duplicate on an endpoint. + // A ring click can still grab an existing node (handled above); only add is box-gated. if (idx < 0) { const bool inBox = (x >= box.left && x < box.left + box.width && y >= box.top && y < box.top + box.height); @@ -115,15 +111,15 @@ void ReaSamplerEditor::onMouseDown(int x, int y) { const int w = cr.right - cr.left; const int h = cr.bottom - cr.top; - // ---- Browse modal (S-VIEW-5): the face branch lives in editor_input_browse_zone ---- + // Browse modal: the face branch lives in editor_input_browse_zone. if (view_ == View::kBrowse) { mouseDownBrowse(w, h, x, y); return; } - // ---- Sample home (S-VIEW-2 / r11) ---- + // Sample home. if (view_ == View::kSample) { - // r11 curve popup: while open the sheet is modal — it owns every left-click. + // The curve popup: while open the sheet is modal — it owns every left-click. if (handlePopupMouseDown(w, h, x, y)) return; const PerformanceZone probeZone = effectiveSampleZone(); @@ -155,8 +151,8 @@ void ReaSamplerEditor::onMouseDown(int x, int y) { invalidate(); return; } - // Radial preview-velocity knob (r11): GRAB-ANCHORED vertical drag — the grab itself - // never jumps the value (FA4); the delta from the grab point maps via knobDragValue. + // Radial preview-velocity knob: grab-anchored vertical drag — the grab itself never + // jumps the value; the delta from the grab point maps via knobDragValue. if (contains(cr.velCell, x, y)) { drag_ = DragKind::kDeckKnob; dragParamId_ = -2; // sentinel: the preview velocity knob (a processor param) @@ -187,9 +183,9 @@ void ReaSamplerEditor::onMouseDown(int x, int y) { return; } - // The knob deck (r11): toggles commit at once (a discrete, final edit — the slider - // precedent); knobs start a grab-anchored vertical drag. The deck band swallows its - // clicks (no fall-through to the hero/markers). + // The knob deck: toggles commit at once (a discrete, final edit); knobs start a + // grab-anchored vertical drag. The deck band swallows its clicks (no fall-through to + // the hero/markers). if (contains(bands.deck, x, y)) { const DeckLayout dl = layoutDeck(deckDescs, bands.deck.x, bands.deck.y, bands.deck.width); @@ -266,7 +262,7 @@ void ReaSamplerEditor::onMouseDown(int x, int y) { return; } - // Hero waveform: envelope nodes (S-VIEW-3) first, then the S11 markers. + // Hero waveform: envelope nodes first, then the wave markers. const std::vector& pcm = monoPcmFor(selectedId_); const std::int64_t frames = static_cast(pcm.size()); const Rect waveArea = bands.hero; @@ -304,7 +300,7 @@ void ReaSamplerEditor::onMouseDown(int x, int y) { } } - // Fenced root strip: grab the root marker (remainder-width since r11). + // Fenced root strip: grab the root marker (remainder-width). if (cr.rootStrip.width > 0) { const StripLayout sl = layoutStrip(cr.rootStrip.width, cr.rootStrip.height); const int note = keyAtPoint(sl, x - cr.rootStrip.x, y - cr.rootStrip.y); @@ -320,7 +316,7 @@ void ReaSamplerEditor::onMouseDown(int x, int y) { return; } - // ---- Zone surface (S-VIEW-8 / FB2): the face branch lives in editor_input_browse_zone ---- + // Zone surface: the face branch lives in editor_input_browse_zone. mouseDownZone(w, h, x, y); } @@ -335,24 +331,24 @@ void ReaSamplerEditor::onMouseMove(int x, int y) { const int dx = x - dragStartX_; if (drag_ == DragKind::kDeckKnob) { - // r11 radial knob: GRAB-ANCHORED vertical drag — knobDragValue maps the y delta from - // the value at grab (up = increase), so the value tracks relative motion and never - // jumps on grab (FA4). Live feedback; zone-param commits land on WM_LBUTTONUP. + // Radial knob: grab-anchored vertical drag — knobDragValue maps the y delta from the + // value at grab (up = increase), so the value tracks relative motion and never jumps + // on grab. Live feedback; zone-param commits land on WM_LBUTTONUP. const int dy = y - dragStartY_; applyDeckKnob(dragParamZone_, dragParamId_, knobDragValue(dragKnobStartValue_, dy)); invalidate(); return; } - // r11: the Sample bands derive from the deck height (mode-independent width math). Hoisted + // The Sample bands derive from the deck height (mode-independent width math). Hoisted // below the kDeckKnob early-return — that branch uses neither deckDescs nor bands. const std::vector deckDescs = deckGroupDescs(effectiveSampleZone().play); const SampleBands bands = computeSampleBands(w, h, deckHeight(deckDescs, w - 2 * kPad)); if (drag_ == DragKind::kRootMarker) { // The fenced root strip on the Sample cluster band. Setting the root materializes a - // full-keyboard zone carrying the override on the picked id (the D-B override vehicle) — - // upsert by id so a repeated drag edits the same zone rather than stacking duplicates. + // full-keyboard zone carrying the override on the picked id — upsert by id so a + // repeated drag edits the same zone rather than stacking duplicates. const ChannelToggleRects chan = channelToggleRects(bands.cluster); const Rect stripArea = clusterRects(bands.cluster, chan.mono, kDeckKnobSize).rootStrip; const StripLayout sl = layoutStrip(stripArea.width, stripArea.height); @@ -381,10 +377,11 @@ void ReaSamplerEditor::onMouseMove(int x, int y) { } if (drag_ == DragKind::kEnvNode) { - // S-VIEW-3: resolve the grabbed envelope node's new params from the pixel delta (through - // the pure envelope_edit inverse map, clamped + monotonic), then unpack them back onto the - // picked id's one-zone play params. The AmpEnvelope was snapshotted at grab (dragStartEnv_) - // so the delta is absolute. Materialize the zone if needed (mirror of the marker path). + // Resolve the grabbed envelope node's new params from the pixel delta (through the + // pure envelope_edit inverse map, clamped + monotonic), then unpack them back onto the + // picked id's one-zone play params. The AmpEnvelope was snapshotted at grab + // (dragStartEnv_) so the delta is absolute. Materialize the zone if needed (mirror of + // the marker path). const std::int64_t frames = dragSampleFrames_; const double rate = liveSampleRate(); if (frames <= 0 || rate <= 0.0) return; @@ -403,9 +400,9 @@ void ReaSamplerEditor::onMouseMove(int x, int y) { } if (drag_ == DragKind::kCurveNode) { - // S-VIEW-10: resolve the grabbed control point from the pixel delta through the pure - // inverse map (box + neighbour-X + endpoint-pin clamps), against the grab-time curve + - // box (absolute delta — the mirror of the envelope-node drag). Live feedback only; the + // Resolve the grabbed control point from the pixel delta through the pure inverse map + // (box + neighbour-X + endpoint-pin clamps), against the grab-time curve + box + // (absolute delta — the mirror of the envelope-node drag). Live feedback only; the // commit lands on WM_LBUTTONUP. if (dragCurveZone_ < 0 || dragCurveZone_ >= static_cast(map_.zones.size())) return; if (curvePointIndex_ < 0) return; @@ -419,8 +416,8 @@ void ReaSamplerEditor::onMouseMove(int x, int y) { } if (drag_ == DragKind::kWaveMarker) { - // S11: resolve the grabbed marker's new frame from the pixel delta, zero-crossing-snap - // it against the decoded PCM, apply the inter-marker clamps, and write the override live. + // Resolve the grabbed marker's new frame from the pixel delta, zero-crossing-snap it + // against the decoded PCM, apply the inter-marker clamps, and write the override live. const Rect waveArea = bands.hero; const std::int64_t frames = dragSampleFrames_; if (frames <= 0) return; @@ -431,8 +428,8 @@ void ReaSamplerEditor::onMouseMove(int x, int y) { dragStartMarkers_.loopEnd}; std::int64_t newFrame = resolveDragFrame(waveArea, frames, startVals[idx], dx); - // Snap to the nearest zero crossing in the decoded PCM (the S2 zero-crossing-aware - // requirement). Pure over the cached mono frames — no host types, no file I/O. + // Snap to the nearest zero crossing in the decoded PCM. Pure over the cached mono + // frames — no host types, no file I/O. const std::vector& pcm = monoPcmFor(selectedId_); if (!pcm.empty()) { newFrame = nearestZeroCrossing(pcm.data(), static_cast(pcm.size()), @@ -464,8 +461,8 @@ void ReaSamplerEditor::onMouseMove(int x, int y) { } if (drag_ == DragKind::kScrollThumb) { - // S12: map the thumb-drag pixel delta to a new (clamped) scroll offset. The scroll drag - // only happens in the Browse modal (the sole card grid). The visible-card window recomputes + // Map the thumb-drag pixel delta to a new (clamped) scroll offset. The scroll drag only + // happens in the Browse modal (the sole card grid). The visible-card window recomputes // at paint from scrollOffset_. const int dyThumb = y - dragStartY_; const BrowseModal bm = computeBrowseModal(w, h); @@ -535,9 +532,9 @@ void ReaSamplerEditor::onMouseUp(int x, int y) { invalidate(); return; } - // S-VIEW-10 drag-off delete: releasing a curve-node drag well OUTSIDE the box removes the - // dragged point (deletePoint refuses the two endpoints, so an endpoint drag-off is a plain - // move — its amp keeps the last clamped drag value). + // Drag-off delete: releasing a curve-node drag well outside the box removes the dragged + // point (deletePoint refuses the two endpoints, so an endpoint drag-off is a plain move — + // its amp keeps the last clamped drag value). if (kind == DragKind::kCurveNode && curveIdx >= 0 && curveZone >= 0 && curveZone < static_cast(map_.zones.size())) { const bool off = x < curveRect.x - kCurveDragOffMargin || @@ -554,12 +551,12 @@ void ReaSamplerEditor::onMouseUp(int x, int y) { } void ReaSamplerEditor::onMouseRDown(int x, int y) { - // r11 (issue 3c): right-click on a popup curve node deletes it — the PRIMARY delete - // affordance; Alt-click and drag-off remain as landed alternates. Commits immediately - // through the same path as Alt-click; deletePoint's endpoint guard makes an endpoint - // right-click a safe no-op. Right-clicks act ONLY while the popup is open — over the - // Sample face OR the Zone surface (FB2; nothing else in the editor consumes them) — - // and never during an in-flight left drag. + // Right-click on a popup curve node deletes it — the primary delete affordance; Alt-click + // and drag-off remain as landed alternates. Commits immediately through the same path as + // Alt-click; deletePoint's endpoint guard makes an endpoint right-click a safe no-op. + // Right-clicks act only while the popup is open — over the Sample face or the Zone + // surface (nothing else in the editor consumes them) — and never during an in-flight left + // drag. if (!processor_ || view_ == View::kBrowse || !curvePopupOpen_) return; if (drag_ != DragKind::kNone) return; RECT rc{}; diff --git a/src/shell/instrument/editor_internal.h b/src/shell/instrument/editor_internal.h index 438eadf..c3ea21d 100644 --- a/src/shell/instrument/editor_internal.h +++ b/src/shell/instrument/editor_internal.h @@ -1,11 +1,8 @@ -// editor_internal.h — INTERNAL shared helpers for the ReaSamplerEditor TU family -// (Q-W2v: the eight face-axis TUs split out of the former reasampler_editor.cpp). -// Included ONLY by the editor's own shell TUs (editor_session / editor_controls / -// editor_paint_* / editor_input_* / editor_platform) — never a public seam. Holds the -// former god-TU's anonymous-namespace helpers that more than one split TU needs: the -// Rect<->kit adapters, the small draw primitives (knob face / spectral strip / root -// marker / title band), the label helpers, the deck group ids, and the velocity-curve -// box derivation. All inline; behavior-identical to the pre-split definitions. +// editor_internal.h — shared helpers for the ReaSamplerEditor TU family. Included ONLY by +// the editor's own shell TUs (editor_session / editor_controls / editor_paint_* / +// editor_input_* / editor_platform) — never a public seam. Holds the Rect<->kit adapters, +// small draw primitives (knob face / spectral strip / root marker / title band), label +// helpers, deck group ids, and the velocity-curve box derivation. All inline. #pragma once @@ -24,7 +21,7 @@ #include "core/audio/peaks.h" // Envelope (drawEnvelope) #include "core/instrument/ui/capture_browser.h" // BrowserLayout / cardThumbnailRect (thumbBins) -#include "core/instrument/ui/param_slider.h" // KnobGeometry / KnobArc (drawKnobFace, FA4) +#include "core/instrument/ui/param_slider.h" // KnobGeometry / KnobArc (drawKnobFace) #include "core/instrument/ui/keyboard_strip.h" // StripLayout / keyRect / isNaturalKey (spectral strip) #include "core/ui/component_geometry.h" // KitBox / waveformColumnCount #include "core/ui/theme.h" // Role / InteractionState / KitColor / spectralColor @@ -33,8 +30,7 @@ namespace reasampler::vst { -// The deck group ids (shell-owned; knob_deck treats them opaquely). Left-to-right deck -// order. Shared by the deck-desc builders (editor_controls) and the deck painter. +// Deck group ids (shell-owned; knob_deck treats them opaquely), left-to-right order. enum DeckGroup { kGroupAmpEnv = 0, kGroupPitch, @@ -43,17 +39,14 @@ enum DeckGroup { kGroupMaster, }; -// The S-VIEW-10 velocity-curve editor box metrics. Since r11/FB2 BOTH surfaces host the -// curve in the POPUP (curve_popup), each summoned from its own mini preview button. The -// INSET keeps node handles + the pick radius inside the border so an endpoint at amp 0/1 -// stays grabbable — the ONE curveBoxFromRect grammar the popup derives its mapping box -// through. Drag-off: release beyond box+margin deletes the dragged node. +// Velocity-curve editor box metrics. The inset keeps node handles + the pick radius +// inside the border so an endpoint at amp 0/1 stays grabbable; drag-off beyond +// box+margin deletes the dragged node. inline constexpr int kVelCurveInset = 14; inline constexpr int kCurveDragOffMargin = 24; -// The pure-module mapping Box for a drawn curve rect: inset from the border so node -// handles and the pick radius stay inside the box. Every consumer (paint, hit-test, add, -// drag) derives the Box through this ONE formula, so drawn nodes and grabs never drift. +// The pure-module mapping Box for a drawn curve rect. Every consumer (paint, hit-test, +// add, drag) derives it through this ONE formula, so drawn nodes and grabs never drift. inline instrument::engine::VelocityCurve::Box curveBoxFromRect( const instrument::ui::Rect& r) { return instrument::engine::VelocityCurve::Box{ @@ -74,8 +67,8 @@ inline std::string noteLabel(int note) { } // A display name for a bank sample id: the snapshotted bank list first, then the -// instance-OWNED ref's displayName (pS — the label survives with the extension absent / -// bank unreadable). "?" only when neither source knows the id. +// instance-owned ref's displayName (survives with the extension absent). "?" if neither +// source knows the id. inline std::string sampleLabel(const std::vector& samples, const instrument::map::SampleRefs& refs, const std::string& id) { @@ -90,11 +83,9 @@ inline std::string sampleLabel(const std::vector& #ifdef _WIN32 -// --- Rect <-> kit adapters (Phase L, L3) ------------------------------------- -// // The editor's own sub-rect type is `Rect` (editor_geometry); the kit draws against // `KitBox` (component_geometry). This is the single boundary that bridges them so every -// draw routes through the L1 kit (theme roles + draw_kit). +// draw routes through the shared kit (theme roles + draw_kit). inline ui::KitBox toKitBox(const instrument::ui::Rect& r) { return ui::KitBox{r.x, r.y, r.width, r.height}; } @@ -110,23 +101,22 @@ inline void kitTextCentered(LICE_IBitmap* bmp, const instrument::ui::Rect& r, text(bmp, toKitBox(r), s, font, role, Align::Center); } -// Draw a peak envelope in `r` through the kit's shared waveform primitive (Phase L, L3). +// Draw a peak envelope in `r` through the kit's shared waveform primitive. inline void drawEnvelope(LICE_IBitmap* bmp, const instrument::ui::Rect& r, const audio::Envelope& env) { drawWaveform(bmp, toKitBox(r), env); } -// The bin count a card's thumbnail is computed at: one bin per drawn pixel column — the -// gap-free render comes from peaks::columnMinMax's exact partition, not from extra bins. -// thumbnailFor clamps the request to the decoded frame count. +// The bin count a card's thumbnail is computed at: one bin per drawn pixel column (the +// gap-free render comes from peaks::columnMinMax's exact partition). inline int thumbBins(const instrument::ui::BrowserLayout& layout) { return (std::max)(1, kWaveformOversample * ui::waveformColumnCount(toKitBox( instrument::ui::cardThumbnailRect(layout, 0)))); } -// Draw the title band with the live readout. Shared by the Sample face (nav visible) — -// Browse/Zone draw their own back button in place of the nav. +// Draws the title band with the live readout. Browse/Zone draw their own back button in +// place of the nav. inline void drawTitleBand(LICE_IBitmap* bmp, const instrument::ui::Rect& title, const std::string& readout) { fillSurface(bmp, toKitBox(title), ui::Role::BgPanel, ui::InteractionState::Rest); @@ -135,12 +125,10 @@ inline void drawTitleBand(LICE_IBitmap* bmp, const instrument::ui::Rect& title, kitText(bmp, titleText, readout.c_str(), Font::Title, ui::Role::TextPrimary); } -// Draw one radial knob face (r11): the FA4 param_slider primitive owns the value<->angle -// map; this turns it into LICE calls through the kit's palette roles. LICE's arc -// convention matches param_slider's (angle 0 = 12 o'clock, positive clockwise) — but LICE -// takes RADIANS, and drawing the 7->5 o'clock sweep THROUGH the top needs a continuous -// angle span, so the degrees convert as (deg - 360) * pi/180, mapping 210..510 onto -// -150..+150 degrees. One conversion, both arcs. +// Draws one radial knob face: param_slider owns the value<->angle map; this turns it into +// LICE calls. LICE takes radians, and drawing the 7->5 o'clock sweep through the top needs +// a continuous angle span, so degrees convert as (deg - 360) * pi/180, mapping 210..510 +// onto -150..+150 degrees. inline void drawKnobFace(LICE_IBitmap* bmp, const instrument::ui::Rect& knobRect, double value01, ui::InteractionState st) { using instrument::ui::KnobArc; @@ -149,14 +137,13 @@ inline void drawKnobFace(LICE_IBitmap* bmp, const instrument::ui::Rect& knobRect const KnobGeometry kg = instrument::ui::computeKnob(knobRect); if (kg.radius <= 1.0) return; constexpr double kDegToRad = 3.14159265358979323846 / 180.0; - const KnobArc arc{}; // the FA4 default 7->5 o'clock sweep + const KnobArc arc{}; // the default 7->5 o'clock sweep const float cx = static_cast(kg.centerX); const float cy = static_cast(kg.centerY); const float rOuter = static_cast(kg.radius) - 0.5f; const bool disabled = (st == ui::InteractionState::Disabled); const bool hot = (st == ui::InteractionState::Dragging || st == ui::InteractionState::Hover); - // Face: a filled circle in the cell surface color under the interaction state. LICE_FillCircle(bmp, cx, cy, rOuter - 1.f, toLice(ui::roleColorState(ui::Role::BgCell, st)), 1.0f, 0, true); // Track: the full sweep as a hairline arc (the dead 60-degree arc at the bottom stays bare). @@ -165,8 +152,6 @@ inline void drawKnobFace(LICE_IBitmap* bmp, const instrument::ui::Rect& knobRect (arc.startDeg + instrument::ui::knobSweepDeg(arc) - 360.0) * kDegToRad); LICE_Arc(bmp, cx, cy, rOuter, a0, a1, toLice(ui::roleColor(ui::Role::LineHairline)), 1.0f, 0, true); - // Value arc: start -> the value's angle, in the live accent (hot while under the pointer / - // dragging, dim when disabled). const double v = value01 < 0.0 ? 0.0 : (value01 > 1.0 ? 1.0 : value01); if (v > 0.0) { const float av = static_cast( @@ -185,11 +170,9 @@ inline void drawKnobFace(LICE_IBitmap* bmp, const instrument::ui::Rect& knobRect toLice(ui::roleColor(needleRole)), 1.0f, 0, true); } -// Draw the pastel spectral keyboard-strip background (Phase L, L3) — the signature -// surface. Fills each MIDI key column with its spectral hue, then draws faint per-octave -// hairline ticks. Shared by the setup face + the Zones strip so both read as the same -// spectrum. S-VIEW-7: accidentals get a dark bg/base wash over the hue (an OVERLAY, not -// a keyboard shape) so pitch position reads as a keyboard at a glance. +// Draws the pastel spectral keyboard-strip background: each MIDI key column filled with +// its spectral hue, accidentals darkened with an overlay wash so pitch position reads as +// a keyboard at a glance. Shared by the setup face + the Zones strip. inline void drawSpectralStrip(LICE_IBitmap* bmp, const instrument::ui::Rect& stripArea) { using instrument::ui::StripLayout; if (stripArea.width <= 0 || stripArea.height <= 0) return; @@ -218,8 +201,8 @@ inline void drawSpectralStrip(LICE_IBitmap* bmp, const instrument::ui::Rect& str } } -// Draw the single-capture root marker on the strip: an accent-primary bar with a soft -// STATIC glow (a wider, lower-alpha accent bar behind it) — the "this is live" mark. +// Draws the single-capture root marker: an accent-primary bar with a soft static glow — +// the "this is live" mark. inline void drawRootMarker(LICE_IBitmap* bmp, const instrument::ui::Rect& stripArea, const instrument::ui::StripLayout& sl, int root) { const int sx = stripArea.x; diff --git a/src/shell/instrument/editor_paint_browse_zone.cpp b/src/shell/instrument/editor_paint_browse_zone.cpp index 60c5d82..309eb05 100644 --- a/src/shell/instrument/editor_paint_browse_zone.cpp +++ b/src/shell/instrument/editor_paint_browse_zone.cpp @@ -1,10 +1,9 @@ -// editor_paint_browse_zone.cpp — the ReaSamplerEditor's BROWSE-MODAL and ZONE-SURFACE -// painting (Q-W2v split of reasampler_editor.cpp, T4-11): the full-window select-then- -// confirm picker (S-VIEW-5 — wash, search box, filter tabs, card grid, scrollbar, -// footer) and the Zone keymap surface (S-VIEW-8/FB2 — add/delete, the spectral zones -// strip, the numeric-entry legend, the per-zone knob deck + curve button). Windows-only -// (D5). Shares the Sample face's painters (title band / empty state / deck / curve -// button / popup) via the class + editor_internal.h. +// editor_paint_browse_zone.cpp — the ReaSamplerEditor's browse-modal and zone-surface +// painting: the full-window select-then-confirm picker (wash, search box, filter tabs, card +// grid, scrollbar, footer) and the Zone keymap surface (add/delete, the spectral zones +// strip, the numeric-entry legend, the per-zone knob deck + curve button). Windows-only. +// Shares the Sample face's painters (title band / empty state / deck / curve button / +// popup) via the class + editor_internal.h. #include "shell/instrument/reasampler_editor.h" @@ -15,8 +14,8 @@ #include #include -#include "core/instrument/ui/browser_scroll.h" // BrowseModal + scroll/search geometry (S12) -#include "core/instrument/ui/knob_deck.h" // the per-zone deck layout (FB2) +#include "core/instrument/ui/browser_scroll.h" // BrowseModal + scroll/search geometry +#include "core/instrument/ui/knob_deck.h" // the per-zone deck layout #include "shell/instrument/editor_internal.h" // kit adapters + spectral strip + labels #include "shell/instrument/reasampler_processor.h" @@ -27,8 +26,8 @@ using namespace reasampler::instrument::ui; // browser/strip/deck/zone-surface using namespace reasampler::instrument::map; // SampleChoice / BankChoice / SampleRefs void ReaSamplerEditor::paintBrowse(LICE_IBitmap* bmp, int w, int h) { - // A full-window modal sheet over the Sample face (F3: full-window overlay). Dim the underlying - // Sample face with a bg/base wash, then draw the picker opaque on top. + // A full-window modal sheet over the Sample face. Dim the underlying Sample face with a + // bg/base wash, then draw the picker opaque on top. LICE_FillRect(bmp, 0, 0, w, h, toLice(roleColor(Role::BgBase)), 0.82f, 0); const BrowseModal bm = computeBrowseModal(w, h); @@ -84,8 +83,9 @@ void ReaSamplerEditor::paintBrowse(LICE_IBitmap* bmp, int w, int h) { active ? Role::BgBase : Role::TextPrimary); } - // Cards (the S12 visible window at the current scroll offset). The PENDING pick (browsePendingId_) - // is marked with the accent-primary border; the currently-loaded id gets a faint tertiary border. + // Cards (the visible window at the current scroll offset). The pending pick + // (browsePendingId_) is marked with the accent-primary border; the currently-loaded id + // gets a faint tertiary border. const int bins = thumbBins(bl); const int cardCount = static_cast(visible_.size()); const VisibleRange vr = visibleCardRange(bl, cardCount, scrollOffset_); @@ -192,8 +192,8 @@ void ReaSamplerEditor::paintZone(LICE_IBitmap* bmp, int w, int h) { drawButton(bmp, box, "Delete", state, /*warn=*/false); } - // The zones strip — the same PASTEL SPECTRAL surface as the Sample face, with one bar per - // zone over the spectrum. The SELECTED zone lifts to accent-primary + a static glow ("which + // The zones strip — the same pastel spectral surface as the Sample face, with one bar per + // zone over the spectrum. The selected zone lifts to accent-primary + a static glow ("which // zone is live"); the rest take the categorical secondary hue at low alpha. const Rect stripArea = zonesStripArea(content); drawSpectralStrip(bmp, stripArea); @@ -218,8 +218,8 @@ void ReaSamplerEditor::paintZone(LICE_IBitmap* bmp, int w, int h) { } // A one-line legend of the selected zone below the strip, with three click-to-type numeric - // entry fields (low / high / root) — S12 direct numeric entry. Clicking a field focuses it - // (entryField_) and typed text commits via parseNoteEntry on Enter. + // entry fields (low / high / root). Clicking a field focuses it (entryField_) and typed + // text commits via parseNoteEntry on Enter. const int legendTop = stripArea.bottom() + 8; Rect infoR = Rect::ltrb(stripArea.x, legendTop, stripArea.right(), legendTop + 18); if (selectedZone_ >= 0 && selectedZone_ < static_cast(map_.zones.size())) { @@ -256,19 +256,18 @@ void ReaSamplerEditor::paintZone(LICE_IBitmap* bmp, int w, int h) { Font::Label, Role::TextDim); } - // The per-zone parameter surface for the selected zone. FB2 (R11-F2): the SAME knob deck + - // curve-preview-button/popup grammar as the Sample face — one control language over the one - // storage site (S15-F2) — replacing the retired param_slider rows + inline curve box. Only - // the per-zone groups render here; VOICE/MASTER are per-instance (ComponentState) and live - // on the Sample deck only. + // The per-zone parameter surface for the selected zone: the same knob deck + + // curve-preview-button/popup grammar as the Sample face — one control language over the + // one storage site. Only the per-zone groups render here; VOICE/MASTER are per-instance + // (ComponentState) and live on the Sample deck only. if (selectedZone_ >= 0 && selectedZone_ < static_cast(map_.zones.size())) { const PerformanceZone& z = map_.zones[static_cast(selectedZone_)]; paintKnobDeck(bmp, zonesDeckArea(content), z, zoneDeckGroupDescs(z.play)); paintCurveButton(bmp, zonesCurveButton(content), z); } - // The curve popup (FB2): a centered sheet over the whole Zone surface, drawn LAST — - // the same modal grammar as the Sample face. + // The curve popup: a centered sheet over the whole Zone surface, drawn last — the same + // modal grammar as the Sample face. if (curvePopupOpen_) paintCurvePopup(bmp, w, h); } diff --git a/src/shell/instrument/editor_paint_sample.cpp b/src/shell/instrument/editor_paint_sample.cpp index 6194f9d..ced83e8 100644 --- a/src/shell/instrument/editor_paint_sample.cpp +++ b/src/shell/instrument/editor_paint_sample.cpp @@ -1,10 +1,9 @@ -// editor_paint_sample.cpp — the ReaSamplerEditor's SAMPLE-FACE painting (Q-W2v split of -// reasampler_editor.cpp, T4-11): the WM_PAINT dispatch, the r11 Sample home face (title -// band + elastic hero waveform + root/preview cluster + bottom-anchored knob deck), the -// S-VIEW-3 envelope overlay, the velocity-curve editor + mini preview button + popup -// sheet (shared painters the Zone surface reuses, FB2), and the empty state. Windows-only -// (D5); draws through the L1 kit by palette role. All layout math is pure -// (editor_geometry / knob_deck / curve_popup) — this TU only draws. +// editor_paint_sample.cpp — the ReaSamplerEditor's sample-face painting: the WM_PAINT +// dispatch, the Sample home face (title band + elastic hero waveform + root/preview cluster +// + bottom-anchored knob deck), the envelope overlay, the velocity-curve editor + mini +// preview button + popup sheet (shared painters the Zone surface reuses), and the empty +// state. Windows-only; draws through the shared kit by palette role. All layout math is +// pure (editor_geometry / knob_deck / curve_popup) — this TU only draws. #include "shell/instrument/reasampler_editor.h" @@ -17,10 +16,10 @@ #include #include "core/audio/peaks.h" // computeEnvelope (hero waveform binning) -#include "core/instrument/ui/curve_popup.h" // r11 centered curve-popup sheet geometry (FB1) +#include "core/instrument/ui/curve_popup.h" // centered curve-popup sheet geometry #include "core/instrument/ui/knob_deck.h" // deck layout + kDeckKnobSize -#include "core/instrument/ui/waveform_view.h" // frameToX (S11 markers) -#include "core/version/app_version.h" // vstPluginName (channel-derived title band, S18) +#include "core/instrument/ui/waveform_view.h" // frameToX (waveform markers) +#include "core/version/app_version.h" // vstPluginName (channel-derived title band) #include "shell/instrument/editor_internal.h" // kit adapters + knob face/spectral strip/root marker #include "shell/instrument/reasampler_processor.h" @@ -32,8 +31,8 @@ using namespace reasampler::instrument::map; // SampleRefs / findRef (title read using audio::computeEnvelope; namespace { -// Marker roles (Phase L, L3) — semantic, drawn through the kit's palette: start = teal -// (secondary), loop start/end = purple (tertiary). The loop-span fill is a faint purple. +// Marker roles — semantic, drawn through the kit's palette: start = teal (secondary), loop +// start/end = purple (tertiary). The loop-span fill is a faint purple. constexpr Role kRoleStartMarker = Role::AccentSecondary; constexpr Role kRoleLoopMarker = Role::AccentTertiary; } // namespace @@ -48,9 +47,9 @@ void ReaSamplerEditor::paint(HDC hdc) { LICE_SysBitmap bmp(w, h); LICE_Clear(&bmp, toLice(roleColor(Role::BgBase))); - // S-VIEW-1 three-view dispatch. Sample is home; Browse is a full-window modal overlay drawn - // OVER Sample; Zone is the dedicated surface. In the Browse view we draw Sample first so the - // modal reads as a sheet layered over the home face (the "picker over the document" grammar). + // Three-view dispatch. Sample is home; Browse is a full-window modal overlay drawn over + // Sample; Zone is the dedicated surface. In the Browse view we draw Sample first so the + // modal reads as a sheet layered over the home face. if (view_ == View::kZone) { paintZone(&bmp, w, h); } else { @@ -58,9 +57,9 @@ void ReaSamplerEditor::paint(HDC hdc) { if (view_ == View::kBrowse) paintBrowse(&bmp, w, h); } - // S13 (relay degraded): a transient banner flashed after a file was dropped ON THIS window. - // It reiterates the shipped ingest gesture rather than swallowing the drop silently. Drawn - // LAST so it overlays whatever view is up; decays via onSyncTimer (dropHintTicks_). + // A transient banner flashed after a file was dropped on this window. It reiterates the + // shipped ingest gesture rather than swallowing the drop silently. Drawn last so it + // overlays whatever view is up; decays via onSyncTimer (dropHintTicks_). if (dropHintTicks_ > 0) { const int bannerTop = (std::min)(kTitleHeight, h); const int bannerH = (std::min)(kTitleHeight + 8, (std::max)(0, h - bannerTop)); @@ -77,21 +76,20 @@ void ReaSamplerEditor::paint(HDC hdc) { } void ReaSamplerEditor::paintSample(LICE_IBitmap* bmp, int w, int h) { - // r11: the deck height comes from the pure knob_deck wrap (mode-independent — the AMP - // ENVELOPE group reserves its 5-cell Gate width, so Gate<->Trigger never changes it). + // The deck height comes from the pure knob_deck wrap (mode-independent — the AMP ENVELOPE + // group reserves its 5-cell Gate width, so Gate<->Trigger never changes it). const PerformanceZone deckZone = effectiveSampleZone(); const std::vector deckDescs = deckGroupDescs(deckZone.play); const SampleBands bands = computeSampleBands(w, h, deckHeight(deckDescs, w - 2 * kPad)); - // Title: product name + live readout. Standard B palette — the beta channel gets NO distinct - // accent (settled 2026-07-27); the channel-derived vstPluginName is the only beta-vs-stable - // signal. - std::string title = version::vstPluginName(); // channel-derived (S18) + // Title: product name + live readout. The beta channel gets no distinct accent; the + // channel-derived vstPluginName is the only beta-vs-stable signal. + std::string title = version::vstPluginName(); if (processor_ && processor_->bridge().isConnected()) { - // The instance's OWN loaded state outranks bank availability (pS: the bank is a - // browser source, not the instrument's identity) — a self-contained instance names - // its sound (refs displayName fallback) even when the bank snapshot is empty. + // The instance's own loaded state outranks bank availability (the bank is a browser + // source, not the instrument's identity) — a self-contained instance names its sound + // (refs displayName fallback) even when the bank snapshot is empty. if (!map_.zones.empty()) title += " [" + std::to_string(map_.zones.size()) + " zone(s)]"; else if (!selectedId_.empty()) title += " [" + sampleLabel(samples_, processor_->sampleRefs(), selectedId_) + "]"; @@ -128,17 +126,17 @@ void ReaSamplerEditor::paintSample(LICE_IBitmap* bmp, int w, int h) { } // Resolve the effective single-capture zone: the picked id's one-zone override when present, - // else the product-default play params (S15-F2 — the single capture is a one-zone map). This - // is the ONE storage site both Sample and Zone edit. + // else the product-default play params (the single capture is a one-zone map). This is the + // one storage site both Sample and Zone edit. const PerformanceZone& zone = deckZone; - // --- Hero waveform band: envelope + S11 markers + S-VIEW-3 envelope overlay ----------- + // Hero waveform band: envelope + markers + envelope overlay. const std::vector& pcm = monoPcmFor(selectedId_); const std::int64_t frames = static_cast(pcm.size()); const Rect waveArea = bands.hero; fillSurface(bmp, toKitBox(waveArea), Role::BgBase, InteractionState::Rest); if (frames > 0 && waveArea.width > 0) { - // FA3 gap-free: one bin per drawn pixel column (kWaveformOversample == 1, so this + // Gap-free: one bin per drawn pixel column (kWaveformOversample == 1, so this // multiplies by 1). The gap-free draw comes from peaks::columnMinMax's exact // partition — extra bins produce no visible change. Clamped to frame count below. const std::int64_t wantBins = @@ -168,14 +166,14 @@ void ReaSamplerEditor::paintSample(LICE_IBitmap* bmp, int w, int h) { toLice(roleColor(markerRoles[i])), alpha, 0); } - // S-VIEW-3: trace the amp-envelope overlay + its draggable node handles over the hero. + // Trace the amp-envelope overlay + its draggable node handles over the hero. paintEnvelopeOverlay(bmp, waveArea, zone, frames); } else { kitTextCentered(bmp, waveArea, "(decoding...)", Font::Label, Role::TextDim); } - // --- Root + preview cluster (r11: remainder-width root strip, preview button, radial - // velocity knob, mini curve-preview button, channel toggle) ----------------------------- + // Root + preview cluster: remainder-width root strip, preview button, radial velocity + // knob, mini curve-preview button, channel toggle. fillSurface(bmp, toKitBox(bands.cluster), Role::BgPanel, InteractionState::Rest); const ChannelToggleRects chan = channelToggleRects(bands.cluster); const ClusterRects cr = clusterRects(bands.cluster, chan.mono, kDeckKnobSize); @@ -193,7 +191,7 @@ void ReaSamplerEditor::paintSample(LICE_IBitmap* bmp, int w, int h) { : (isHovered(HoverKind::kPreview, -1) ? InteractionState::Hover : InteractionState::Rest); drawButton(bmp, box, "Preview", st, /*warn=*/false); } - // Preview velocity: a RADIAL knob cell (r11 — the deck cell grammar), bound to the same + // Preview velocity: a radial knob cell (the deck cell grammar), bound to the same // persisted previewVelocity seam. Label swaps to the live value during hover/drag. { const bool dragging = (drag_ == DragKind::kDeckKnob && dragParamId_ == -2); @@ -211,8 +209,8 @@ void ReaSamplerEditor::paintSample(LICE_IBitmap* bmp, int w, int h) { kitTextCentered(bmp, cr.velLabel, "Vel", Font::Micro, Role::TextDim); } } - // The mini curve-preview button (r11): opens the popup editor. Shared painter with the - // Zone panel's button (FB2 — one grammar on both surfaces). + // The mini curve-preview button: opens the popup editor. Shared painter with the Zone + // panel's button — one grammar on both surfaces. paintCurveButton(bmp, cr.curveBtn, zone); // Mono | Stereo output-mode toggle. { @@ -227,10 +225,10 @@ void ReaSamplerEditor::paintSample(LICE_IBitmap* bmp, int w, int h) { kitTextCentered(bmp, chan.stereo, "Stereo", Font::Label, isStereo ? Role::BgBase : Role::TextPrimary); } - // --- The knob deck (r11: the fenced control groups, bottom-anchored) ------------------- + // The knob deck: the fenced control groups, bottom-anchored. paintKnobDeck(bmp, bands.deck, zone, deckDescs); - // --- The curve popup (r11): a centered sheet over the whole Sample face, drawn LAST ---- + // The curve popup: a centered sheet over the whole Sample face, drawn last. if (curvePopupOpen_) paintCurvePopup(bmp, w, h); } @@ -252,11 +250,11 @@ void ReaSamplerEditor::paintEnvelopeOverlay(LICE_IBitmap* bmp, const Rect& waveA const int x1 = (std::max)(waveArea.x, (std::min)(waveArea.right() - 1, poly[i].x)); LICE_Line(bmp, x0, poly[i - 1].y, x1, poly[i].y, line, 1.0f, 0, true); } - // Draggable node handles: a small square per DRAGGABLE node (Origin + ReleaseStart are draw- - // only). Lit accent-hot when this node is the grabbed one. FA2 guarantees every vertex is - // in-bounds (the pre-FA2 right-edge clip is dead and removed — edge nodes like ReleaseEnd - // at area.right()-1 MUST get handles); the handle SQUARE is additionally clamped inside the - // hero rect so a 6px box on an edge node never overhangs into the neighbouring bands. + // Draggable node handles: a small square per draggable node (Origin + ReleaseStart are + // draw-only). Lit accent-hot when this node is the grabbed one. Every vertex is + // guaranteed in-bounds (edge nodes like ReleaseEnd at area.right()-1 must get handles); + // the handle square is additionally clamped inside the hero rect so a 6px box on an edge + // node never overhangs into the neighbouring bands. const LICE_pixel handle = toLice(roleColor(Role::AccentPrimary)); const LICE_pixel handleHot = toLice(roleColor(Role::AccentHot)); for (const EnvVertex& v : poly) { @@ -274,8 +272,8 @@ void ReaSamplerEditor::paintVelocityCurve(LICE_IBitmap* bmp, const Rect& r, if (r.width <= 0 || r.height <= 0) return; // defensive (degenerate rect) // The bordered box: a panel surface + hairline border, drawn by palette role. No corner - // caption — the popup sheet's own "VELOCITY -> AMP" title labels this context (FB2: the - // popup is the only host). + // caption — the popup sheet's own "VELOCITY -> AMP" title labels this context (the popup + // is the only host). fillSurface(bmp, toKitBox(r), Role::BgPanel, InteractionState::Rest); LICE_DrawRect(bmp, r.x, r.y, r.width - 1, r.height - 1, toLice(roleColor(Role::LineHairline)), 1.0f, 0); @@ -357,8 +355,8 @@ void ReaSamplerEditor::paintKnobDeck(LICE_IBitmap* bmp, const Rect& deckArea, : (seg1Active ? Role::BgBase : Role::TextPrimary)); }; - // The knob's short name label (swapped for the live value during hover/drag — r11: no - // third line, no permanent value clutter). + // The knob's short name label (swapped for the live value during hover/drag — no third + // line, no permanent value clutter). const auto knobName = [](ParamControl c) -> const char* { switch (c) { case ParamControl::kAttack: return "Attack"; @@ -395,7 +393,7 @@ void ReaSamplerEditor::paintKnobDeck(LICE_IBitmap* bmp, const Rect& deckArea, } kitText(bmp, g.caption, caption, Font::Micro, Role::TextDim); - // The compact caption toggle (r11: right-anchored IN the caption row, never full-width). + // The compact caption toggle (right-anchored in the caption row, never full-width). if (g.captionToggle.id >= 0) { switch (static_cast(g.captionToggle.id)) { case ParamControl::kPlayMode: @@ -422,7 +420,7 @@ void ReaSamplerEditor::paintKnobDeck(LICE_IBitmap* bmp, const Rect& deckArea, } // The knobs. PITCH ENV knobs draw Disabled (not hidden) while the envelope is off — - // stable geometry (r11). + // stable geometry. for (const DeckCellLayout& c : g.cells) { if (c.id < 0) continue; // reserved blank cell (the Trigger face's two spares) const bool disabled = (g.id == kGroupPitchEnv && !play.pitchEnv.enabled); @@ -444,11 +442,11 @@ void ReaSamplerEditor::paintKnobDeck(LICE_IBitmap* bmp, const Rect& deckArea, void ReaSamplerEditor::paintCurveButton(LICE_IBitmap* bmp, const Rect& r, const PerformanceZone& zone) { if (r.width <= 0 || r.height <= 0) return; - // The mini curve-preview button (r11/FB2 — shared by the Sample cluster and the Zone - // panel): a hairline-bordered bg/cell square with the zone's live velocity curve traced - // in miniature (no node markers at this scale). Hover lifts it; it draws ACTIVE - // (accent-primary border) while its popup is open, and re-renders live as the popup - // edits the curve (same zone, re-read each paint). + // The mini curve-preview button (shared by the Sample cluster and the Zone panel): a + // hairline-bordered bg/cell square with the zone's live velocity curve traced in + // miniature (no node markers at this scale). Hover lifts it; it draws Active + // (accent-primary border) while its popup is open, and re-renders live as the popup edits + // the curve (same zone, re-read each paint). const bool hov = isHovered(HoverKind::kCurveButton, -1); fillSurface(bmp, toKitBox(r), Role::BgCell, hov ? InteractionState::Hover : InteractionState::Rest); @@ -489,10 +487,10 @@ void ReaSamplerEditor::paintCurvePopup(LICE_IBitmap* bmp, int w, int h) { : InteractionState::Rest; drawButton(bmp, box, "x", st, /*warn=*/false); } - // The full-size editor: ONE draw path + the one curveBoxFromRect mapping formula, so + // The full-size editor: one draw path + the one curveBoxFromRect mapping formula, so // trace/handles/drag-off cues cannot drift between hosts. The popup edits popupZone() — // the picked capture's one-zone site on the Sample face, the selected zone on the Zone - // surface (FB2). + // surface. paintVelocityCurve(bmp, pl.curveBox, popupZone()); } @@ -502,9 +500,9 @@ void ReaSamplerEditor::paintEmptyState(LICE_IBitmap* bmp, const Rect& area) { const char* msg = samples_.empty() ? "No captures in this project yet - capture audio into the bank to play it here." : "No captures in this bank filter. Choose another bank tab above."; - // Split the area so the primary line sits centered and the S13 ingest affordance sits just - // below it. The affordance is the SHIPPED ingest gesture (drop onto the docked panel) — kept - // discoverable here regardless of whether a drop ever lands on THIS window. + // Split the area so the primary line sits centered and the ingest affordance sits just + // below it. The affordance is the shipped ingest gesture (drop onto the docked panel) — + // kept discoverable here regardless of whether a drop ever lands on this window. Rect primary = Rect::ltrb(area.x, area.y, area.right(), area.y + area.height / 2); Rect hint = Rect::ltrb(area.x, primary.bottom(), area.right(), area.bottom()); kitTextCentered(bmp, primary, msg, Font::Label, Role::TextDim); diff --git a/src/shell/instrument/editor_platform.cpp b/src/shell/instrument/editor_platform.cpp index 3c569df..fa30067 100644 --- a/src/shell/instrument/editor_platform.cpp +++ b/src/shell/instrument/editor_platform.cpp @@ -1,15 +1,14 @@ -// editor_platform.cpp — the ReaSamplerEditor's IPlugView + Win32 window plumbing (Q-W2v -// split of reasampler_editor.cpp, T4-11): platform-type/resize negotiation, the child -// window class + creation/destruction, the S9/S8 sync timer lifetime, the WM_* dispatch -// (wndProc — paint, mouse, keyboard, capture-loss rollback, drop-accept, timer), and the -// non-Windows stubs (D5 makes Windows the only build target; the TU still compiles -// elsewhere). +// editor_platform.cpp — the ReaSamplerEditor's IPlugView + Win32 window plumbing: +// platform-type/resize negotiation, the child window class + creation/destruction, the +// sync timer lifetime, the WM_* dispatch (wndProc — paint, mouse, keyboard, capture-loss +// rollback, drop-accept, timer), and the non-Windows stubs (Windows is the only build +// target; the TU still compiles elsewhere). #include "shell/instrument/reasampler_editor.h" #ifdef _WIN32 #include // GET_X_LPARAM / GET_Y_LPARAM -#include // DragAcceptFiles / DragQueryFile / DragFinish — S13 drop-accept +#include // DragAcceptFiles / DragQueryFile / DragFinish — drop-accept #endif #include "shell/instrument/editor_internal.h" // (transitively: lice + the kit, Windows only) @@ -23,11 +22,11 @@ namespace reasampler::vst { namespace { constexpr const wchar_t* kChildClassName = L"ReaSampler9000VstEditor"; -// The S9/S8 change-detection poll (WM_TIMER on the child window). A low-frequency -// UI-thread timer: responsive enough that a recapture/ingest/assign refreshes "within a -// bounded cadence" (the S9 verify criterion) yet cheap — three small ext-state reads per -// tick, coalescing many bumps between ticks into one reload. 500 ms is a deliberate -// build-time residual. The id is a per-window SetTimer id (any nonzero). +// The change-detection poll (WM_TIMER on the child window). A low-frequency UI-thread +// timer: responsive enough that a recapture/ingest/assign refreshes within a bounded +// cadence, yet cheap — three small ext-state reads per tick, coalescing many bumps +// between ticks into one reload. 500 ms is a deliberate build-time residual. The id is a +// per-window SetTimer id (any nonzero). constexpr UINT_PTR kSyncTimerId = 1; constexpr UINT kSyncTimerIntervalMs = 500; } // namespace @@ -45,12 +44,12 @@ tresult PLUGIN_API ReaSamplerEditor::canResize() { } tresult PLUGIN_API ReaSamplerEditor::checkSizeConstraint(ViewRect* rect) { - // Enforce a minimum usable floor: at least 560 wide and 460 tall. The host calls this before - // every resize; clamp the proposed rect in place and return kResultTrue so the host applies the - // (possibly adjusted) rect rather than the raw user drag. 560×460 keeps the Sample face's title - // + hero waveform + cluster + a few control rows visible (the control strip clips gracefully - // below the panel bottom); anything smaller would clip essential UI. The default 840×620 is - // above this floor. + // Enforce a minimum usable floor: at least 560 wide and 460 tall. The host calls this + // before every resize; clamp the proposed rect in place and return kResultTrue so the host + // applies the (possibly adjusted) rect rather than the raw user drag. 560x460 keeps the + // Sample face's title + hero waveform + cluster + a few control rows visible (the control + // strip clips gracefully below the panel bottom); anything smaller would clip essential UI. + // The default 840x620 is above this floor. constexpr int kMinW = 560; constexpr int kMinH = 460; if (!rect) return kResultFalse; @@ -85,11 +84,11 @@ void ReaSamplerEditor::attachedToParent() { classRegistered = true; } - // Create the kit's cached AA fonts before the first paint (Phase L, L3). Idempotent, so a - // reopen (or a co-resident embed strip that also inits) is a cheap no-op. NOT torn down on - // editor close: the embed strip in the SAME binary shares the kit's process-global font - // set, so a per-view shutdown could free fonts still in use by the other view. The tiny - // static HFONT set is reclaimed by the OS at module unload. See the L3 handoff note. + // Create the kit's cached AA fonts before the first paint. Idempotent, so a reopen (or a + // co-resident embed strip that also inits) is a cheap no-op. Not torn down on editor close: + // the embed strip in the same binary shares the kit's process-global font set, so a + // per-view shutdown could free fonts still in use by the other view. The tiny static HFONT + // set is reclaimed by the OS at module unload. kitFontsInit(); refreshFromBank(); @@ -99,17 +98,17 @@ void ReaSamplerEditor::attachedToParent() { r.getWidth(), r.getHeight(), parent, nullptr, hInst, nullptr); if (childHwnd_) { SetWindowLongPtr(childHwnd_, GWLP_USERDATA, reinterpret_cast(this)); - // S13: accept OS file drops on the editor window (WM_DROPFILES). The drop is NOT - // ingested here (the relay is degraded — see onFilesDropped); accepting it lets us show - // the "drop on the panel" affordance instead of the OS bouncing the drop silently. + // Accept OS file drops on the editor window (WM_DROPFILES). The drop is not ingested + // here (the relay is degraded — see onFilesDropped); accepting it lets us show the + // "drop on the panel" affordance instead of the OS bouncing the drop silently. DragAcceptFiles(childHwnd_, TRUE); - // Start the S9/S8 change-detection poll (UI thread). Tied to the child window's - // lifetime — created here, killed in removedFromParent — so an instance whose editor - // is closed does NOT poll (the editor-open-only cadence; see the handoff limitation). + // Start the change-detection poll (UI thread). Tied to the child window's lifetime — + // created here, killed in removedFromParent — so an instance whose editor is closed + // does not poll. SetTimer(childHwnd_, kSyncTimerId, kSyncTimerIntervalMs, nullptr); - // Poll ONCE immediately so a pending assignment (an S8 ingest fired while this editor - // was closed) or a bank change applies the instant the editor opens, rather than waiting - // up to one timer interval. refreshFromBank above already primed the view; this folds in + // Poll once immediately so a pending assignment (an ingest fired while this editor was + // closed) or a bank change applies the instant the editor opens, rather than waiting up + // to one timer interval. refreshFromBank above already primed the view; this folds in // any pending assign/generation so the just-opened editor shows the assigned capture. onSyncTimer(); } @@ -147,7 +146,7 @@ LRESULT CALLBACK ReaSamplerEditor::wndProc(HWND hwnd, UINT msg, WPARAM wParam, case WM_LBUTTONDOWN: if (self) { SetCapture(hwnd); // keep receiving moves/up if the cursor leaves the child - SetFocus(hwnd); // take keyboard focus so WM_CHAR reaches the search box (S12) + SetFocus(hwnd); // take keyboard focus so WM_CHAR reaches the search box self->onMouseDown(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam)); } return 0; @@ -155,9 +154,9 @@ LRESULT CALLBACK ReaSamplerEditor::wndProc(HWND hwnd, UINT msg, WPARAM wParam, if (self) { const int mx = GET_X_LPARAM(lParam); const int my = GET_Y_LPARAM(lParam); - // Hover feedback (Phase L, L3): resolve the element under the pointer and - // repaint on change. Arm WM_MOUSELEAVE once per "over" cycle so the hover - // clears when the pointer leaves the child (TrackMouseEvent is one-shot). + // Hover feedback: resolve the element under the pointer and repaint on change. + // Arm WM_MOUSELEAVE once per "over" cycle so the hover clears when the pointer + // leaves the child (TrackMouseEvent is one-shot). if (!self->mouseTracking_) { TRACKMOUSEEVENT tme{}; tme.cbSize = sizeof(tme); @@ -182,15 +181,15 @@ LRESULT CALLBACK ReaSamplerEditor::wndProc(HWND hwnd, UINT msg, WPARAM wParam, } return 0; case WM_MOUSEWHEEL: - // S12 browser scroll. GET_WHEEL_DELTA_WPARAM is signed; positive == wheel up. + // Browser scroll. GET_WHEEL_DELTA_WPARAM is signed; positive == wheel up. if (self) self->onMouseWheel(GET_WHEEL_DELTA_WPARAM(wParam)); return 0; case WM_CHAR: - // S12 type-to-filter search keystrokes (only acted on when the search box is focused). + // Type-to-filter search keystrokes (only acted on when the search box is focused). if (self) self->onSearchChar(static_cast(wParam)); return 0; case WM_GETDLGCODE: - // Claim all keys (incl. chars) so the host doesn't eat them before WM_CHAR (S12 search). + // Claim all keys (incl. chars) so the host doesn't eat them before WM_CHAR (search). return DLGC_WANTCHARS | DLGC_WANTARROWS; case WM_LBUTTONUP: if (self) { @@ -199,8 +198,8 @@ LRESULT CALLBACK ReaSamplerEditor::wndProc(HWND hwnd, UINT msg, WPARAM wParam, } return 0; case WM_RBUTTONDOWN: - // r11: right-click — the curve popup's primary node-delete affordance (issue 3c). - // Routed explicitly (the child wndproc historically handled only left-button). + // Right-click — the curve popup's primary node-delete affordance. Routed + // explicitly (the child wndproc historically handled only left-button). if (self) self->onMouseRDown(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam)); return 0; case WM_RBUTTONUP: @@ -232,16 +231,16 @@ LRESULT CALLBACK ReaSamplerEditor::wndProc(HWND hwnd, UINT msg, WPARAM wParam, self->drag_ = DragKind::kNone; self->dragParamId_ = -1; self->dragParamZone_ = -1; - self->curvePointIndex_ = -1; // S-VIEW-10 curve-node drag state (peer reset) + self->curvePointIndex_ = -1; // curve-node drag state (peer reset) self->dragCurveZone_ = -1; self->invalidate(); } } return 0; case WM_DROPFILES: { - // S13 (relay degraded): count the dropped files and flash the affordance. We do NOT - // read/ingest the paths (the instrument never ingests — the relay to the extension is - // unshipped); DragQueryFile with 0xFFFFFFFF just returns the count for the banner. + // Count the dropped files and flash the affordance. We do not read/ingest the paths + // (the instrument never ingests — the relay to the extension is unshipped); + // DragQueryFile with 0xFFFFFFFF just returns the count for the banner. HDROP drop = reinterpret_cast(wParam); const UINT count = DragQueryFileW(drop, 0xFFFFFFFF, nullptr, 0); DragFinish(drop); @@ -258,7 +257,7 @@ LRESULT CALLBACK ReaSamplerEditor::wndProc(HWND hwnd, UINT msg, WPARAM wParam, } } -#else // non-Windows: not a build target (D5), but keep the TU compilable. +#else // non-Windows: not a build target, but keep the TU compilable. void ReaSamplerEditor::attachedToParent() {} void ReaSamplerEditor::removedFromParent() {} diff --git a/src/shell/instrument/editor_session.cpp b/src/shell/instrument/editor_session.cpp index 5042ac1..6fea63c 100644 --- a/src/shell/instrument/editor_session.cpp +++ b/src/shell/instrument/editor_session.cpp @@ -1,10 +1,8 @@ -// editor_session.cpp — the ReaSamplerEditor's SESSION/BRIDGE state (Q-W2v split of -// reasampler_editor.cpp, T4-11): construction, the live-bank snapshot (refreshFromBank / -// rebuildVisible), the S9/S8 sync tick, the commit-and-reload seam, selection loading, -// the picked-capture marker resolution/upsert helpers, and the decoded-PCM + peak -// thumbnail caches (the mirror of bank_panel's, keyed through the pure ThumbnailKey — -// T2-10 rider). UI thread only; every edit commits OFF the audio thread via the -// processor's reloadInstrument. +// editor_session.cpp — the ReaSamplerEditor's session/bridge state: construction, the +// live-bank snapshot (refreshFromBank / rebuildVisible), the sync tick, the +// commit-and-reload seam, selection loading, the picked-capture marker resolution/upsert +// helpers, and the decoded-PCM + peak thumbnail caches. UI thread only; every edit commits +// off the audio thread via the processor's reloadInstrument. #include "shell/instrument/reasampler_editor.h" @@ -14,12 +12,12 @@ #include #include "core/audio/peaks.h" // computeEnvelope (the cached peak thumbnail) -#include "core/capture/capture_paths.h" // resolveBankFile (shared M4 path resolution) +#include "core/capture/capture_paths.h" // resolveBankFile (shared path resolution) #include "core/capture/wav_codec.h" // parseWavLayout, extractFloatFrames -#include "core/ui/bank_grid.h" // ThumbnailKey / thumbnailKeyString (T2-10: the pure key) -#include "core/util/file_bytes.h" // shared whole-file loader (Q-W1, T2-03) +#include "core/ui/bank_grid.h" // ThumbnailKey / thumbnailKeyString (the pure key) +#include "core/util/file_bytes.h" // shared whole-file loader #include "ext_keys.h" -#include "core/instrument/ui/browser_scroll.h" // nameMatchesQuery (S12 type-to-filter) +#include "core/instrument/ui/browser_scroll.h" // nameMatchesQuery (type-to-filter) #include "shell/instrument/reaper_bridge.h" #include "shell/instrument/reasampler_processor.h" @@ -40,11 +38,8 @@ using util::readFileBytes; ReaSamplerEditor::ReaSamplerEditor(ReaSamplerProcessor* processor) : CPluginView(nullptr), processor_(processor) { - // Default view size (S-VIEW-SIZE-1 tuned to the concrete Sample-face band heights). The Sample - // home stacks: title (26) + hero waveform (150) + cluster (52) + the control strip, whose Gate - // mode shows 12 rows at ~26px ≈ 312px. 840×620 clears the full three-band face without scroll - // on a 1080p screen with headroom. Wide enough that the control strip's label + value columns - // read comfortably. + // Default view size, tuned to the Sample-face band heights: title + hero waveform + + // cluster + control strip. 840x620 clears the full face without scroll on 1080p. ViewRect r(0, 0, 840, 620); setRect(r); } @@ -52,7 +47,7 @@ ReaSamplerEditor::ReaSamplerEditor(ReaSamplerProcessor* processor) void ReaSamplerEditor::refreshFromBank() { // Main/UI thread only — reads the live bank over the bridge (allocates, calls REAPER). thumbCache_.clear(); // a bank edit may have re-captured/removed a sample; drop stale peaks - pcmCache_.clear(); // and its decoded PCM (the S11 waveform + snap source) + pcmCache_.clear(); // and its decoded PCM (the waveform + snap source) if (!processor_) { samples_.clear(); banks_.clear(); @@ -69,17 +64,15 @@ void ReaSamplerEditor::refreshFromBank() { const auto prevZoneCount = static_cast(map_.zones.size()); map_ = processor_->performanceMap(); channelMode_ = processor_->channelMode(); - voiceCount_ = processor_->voiceCount(); // Phase S voice-deck snapshot + voiceCount_ = processor_->voiceCount(); voiceMode_ = processor_->voiceMode(); monoTrigger_ = processor_->monoTrigger(); if (selectedZone_ >= static_cast(map_.zones.size())) selectedZone_ = -1; - // r11: a refresh that emptied the selection (a bank change on the sync tick) closes the - // curve popup — the empty-state Sample face no longer draws it, and an open-but-invisible - // modal would swallow clicks. + // A refresh that emptied the selection closes the curve popup — an open-but-invisible + // modal would otherwise swallow clicks on the empty state. if (selectedId_.empty() && map_.zones.empty()) curvePopupOpen_ = false; - // FB2: on the Zone surface the popup edits the SELECTED zone; close it if the zones list - // shrank (selectedZone_ past-end), OR if the zone count changed at all — a mid-list - // deletion leaves selectedZone_ in range but now naming a DIFFERENT zone (silent retarget). + // On the Zone surface, close the popup if the zone count changed at all — a mid-list + // deletion can leave selectedZone_ in range but silently naming a different zone. if (view_ == View::kZone && curvePopupOpen_) { const auto newZoneCount = static_cast(map_.zones.size()); if (selectedZone_ < 0 || newZoneCount != prevZoneCount) curvePopupOpen_ = false; @@ -94,8 +87,7 @@ void ReaSamplerEditor::refreshFromBank() { } void ReaSamplerEditor::rebuildVisible() { - // S12 composition: the bank filter picks the bank FIRST, then the type-to-filter search - // narrows the survivors by name substring (nameMatchesQuery — empty query is the identity). + // Bank filter first, then type-to-filter search narrows by name substring. visible_.clear(); for (const SampleChoice& s : samples_) { const bool inBank = activeFilterBankId_.empty() || s.bankId == activeFilterBankId_; @@ -103,39 +95,30 @@ void ReaSamplerEditor::rebuildVisible() { const std::string& name = s.displayName.empty() ? s.id : s.displayName; if (nameMatchesQuery(name, searchQuery_)) visible_.push_back(s); } - // NOTE: scrollOffset_ is clamped at paint + wheel time (where the browser layout / panel - // height is known); rebuildVisible runs cross-platform + on the sync-timer refresh, so it - // must not reset the user's scroll here. + // scrollOffset_ is clamped at paint/wheel time (where layout is known); this runs on + // the sync-timer refresh too, so it must not reset the user's scroll here. } #ifdef _WIN32 -// Windows-only (the WM_TIMER cadence + invalidate() are the Win32 child-window path). Declared -// under the same _WIN32 guard in the header; keep the definition guarded to match (D5 makes -// Windows the only build target, but the TU must still compile elsewhere). +// Windows-only (the WM_TIMER cadence + invalidate() are the Win32 child-window path). void ReaSamplerEditor::onSyncTimer() { - // UI thread (WM_TIMER). Poll the S9 bank generation + the S8 assignment request via the - // processor (off the audio thread — the poll itself never touches process()). NEVER while a - // drag is in flight: a reload mid-drag would rebuild the instrument and repaint under the - // user's cursor, yanking the edit. The next tick (500 ms) picks up the change after release. + // UI thread (WM_TIMER). Never while a drag is in flight: a reload mid-drag would + // rebuild the instrument and repaint under the cursor, yanking the edit — the next + // tick picks up the change after release. if (!processor_) return; if (drag_ != DragKind::kNone) return; // defer past the in-flight edit - // An open editor marks THIS instance the focused assignment target (the thundering-herd - // policy — only an editor-open instance applies a pending assign; see the handoff). Pass - // true so this instance consumes the request; instances with no editor open do not poll at - // all (the timer is bound to the child window), so they never contend for the request. + // An open editor is the focused assignment target (thundering-herd policy); instances + // with no editor open never poll (the timer is bound to the child window). const ReaSamplerProcessor::BankSyncResult r = processor_->pollBankSync(/*isFocusedTarget=*/true); - // Re-snapshot the editor's own view only when something changed (a reload from a bank - // content change, or an applied assignment). refreshFromBank re-reads the bank blob + the - // processor's (possibly just-updated) selection/map and drops the stale thumbnail/PCM - // caches, then repaints — so the browser + setup surface reflect the new bank hands-free. + // Re-snapshot only when something changed. if (r.reloaded || r.applied) { refreshFromBank(); invalidate(); } - // S13: decay the drop-affordance banner so it auto-dismisses a few ticks after a drop. + // Decay the drop-affordance banner so it auto-dismisses a few ticks after a drop. if (dropHintTicks_ > 0) { --dropHintTicks_; invalidate(); @@ -144,18 +127,16 @@ void ReaSamplerEditor::onSyncTimer() { #endif // _WIN32 void ReaSamplerEditor::commitAndReload() { - // UI thread only. Publish the edited selection + zones to the processor, then rebuild - // the instrument off the audio thread (reloadInstrument bakes them into the live Keymap). - // pS: the reload also COPIES the picked capture's file ref + intrinsics from the bank - // blob into the instance-owned refs table (refreshRefsFromBank) — a browser load is the - // moment the instance becomes self-contained for that sample. + // UI thread only. Publishes the edited selection + zones, then rebuilds off the audio + // thread. The reload also copies the picked capture's file ref + intrinsics into the + // instance-owned refs table — a browser load is the moment the instance becomes + // self-contained for that sample. if (!processor_) return; processor_->setSelectedSampleId(selectedId_); processor_->setPerformanceMap(map_); processor_->reloadInstrument(); - // GA: the reload may have AUTO-DEFAULTED the channel mode from the loaded capture's - // channel count (implicit mode only) — re-read so the Mono/Stereo toggle draws the mode - // the engine actually decoded with. + // The reload may have auto-defaulted the channel mode (implicit only) — re-read so the + // toggle draws what the engine actually decoded with. channelMode_ = processor_->channelMode(); #ifdef _WIN32 invalidate(); @@ -163,10 +144,9 @@ void ReaSamplerEditor::commitAndReload() { } void ReaSamplerEditor::loadSelection(const std::string& id) { - // Zone-bleed fix (3a): a Sample-face load REPLACES the loaded sound. The previous - // sample's materialized full-range zone must not linger — first-match resolve would - // keep playing it while the editor draws the new pick's zone (matched by sampleId, - // order-blind). Authored Zone-view maps (any narrow key range) are left untouched. + // A Sample-face load REPLACES the loaded sound: the previous sample's materialized + // full-range zone must not linger, or first-match resolve would keep playing it. + // Authored Zone-view maps (narrow key ranges) are left untouched. selectedId_ = id; if (reconcileSingleCaptureZones(map_, selectedId_)) { selectedZone_ = map_.zones.empty() ? -1 : 0; @@ -176,11 +156,11 @@ void ReaSamplerEditor::loadSelection(const std::string& id) { ReaSamplerEditor::SetupMarkers ReaSamplerEditor::pickedMarkers(std::int64_t frames) const { SetupMarkers m; - // Seed from the bank's S2 intrinsic loop (fact about the file), then let a per-zone override - // for the picked id win (the instrument's performance choice, D-B). Read the loop intrinsic - // from the live bank blob (the same path selectSample uses); when that is not readable - // (extension absent / not yet parsed) the instance-OWNED ref carries the same intrinsics - // (pS fallback). The override lives in map_. + // Seed from the bank's intrinsic loop (fact about the file), then let a per-zone override + // for the picked id win (the instrument's performance choice). Read the loop intrinsic from + // the live bank blob (the same path selectSample uses); when that is not readable (extension + // absent / not yet parsed) the instance-owned ref carries the same intrinsics. The override + // lives in map_. if (processor_) { std::optional sel; auto banksJson = @@ -215,10 +195,10 @@ ReaSamplerEditor::SetupMarkers ReaSamplerEditor::pickedMarkers(std::int64_t fram } int ReaSamplerEditor::upsertPickedOverride(const SetupMarkers& m) { - // Find-or-append the zone for selectedId_ and write the loop/start override fields. - // The bank intrinsic is NEVER written (read-only bank consumer, D-B). selectedId_ must - // be non-empty; callers are responsible for that guard. - // Returns the zone index (0-based) so callers can update selectedZone_. + // Find-or-append the zone for selectedId_ and write the loop/start override fields. The + // bank intrinsic is never written (read-only bank consumer). selectedId_ must be + // non-empty; callers are responsible for that guard. Returns the zone index (0-based) so + // callers can update selectedZone_. SampleLoop loop; loop.hasLoop = m.hasLoop; loop.start = m.loopStart; @@ -243,8 +223,8 @@ int ReaSamplerEditor::upsertPickedOverride(const SetupMarkers& m) { PerformanceZone ReaSamplerEditor::effectiveSampleZone() const { // The picked id's one-zone override, if the map already carries one; else a product-default - // zone bound to the picked id (NOT appended — a read-only resolve; a control edit materializes - // it via ensureSampleZone). Mirrors the S15-F2 single-storage-site lean. + // zone bound to the picked id (not appended — a read-only resolve; a control edit + // materializes it via ensureSampleZone). for (const PerformanceZone& z : map_.zones) { if (z.sampleId == selectedId_) return z; } @@ -280,11 +260,10 @@ int ReaSamplerEditor::ensureSampleZone() { } void ReaSamplerEditor::commitPickedMarkers(const SetupMarkers& m) { - // Materialize the edited markers as a per-zone loop/start override on the picked id (upsert, - // mirror of the root-marker path): a full-keyboard zone carrying the override. This plays - // identically to the un-zoned single capture (one chromatic zone) and round-trips through - // the component state; the zone becomes visible if the user opens the Zones panel. The bank - // intrinsic is NEVER written (read-only bank consumer, D-B). + // Materialize the edited markers as a per-zone loop/start override on the picked id (upsert): + // a full-keyboard zone carrying the override. This plays identically to the un-zoned single + // capture (one chromatic zone) and round-trips through the component state; the zone becomes + // visible if the user opens the Zones panel. The bank intrinsic is never written. if (selectedId_.empty()) return; upsertPickedOverride(m); commitAndReload(); @@ -294,11 +273,11 @@ const std::vector& ReaSamplerEditor::monoPcmFor(const std::string& auto it = pcmCache_.find(sampleId); if (it != pcmCache_.end()) return it->second; - // SampleChoice is the browser's metadata projection and does NOT carry the WAV path, so + // SampleChoice is the browser's metadata projection and does not carry the WAV path, so // resolve the path from the live bank blob (selectSample) and decode via the shared WAV - // parse — the mirror of the processor's decodeRelative. Every failure path caches an EMPTY - // vector so a broken/missing file is not re-decoded on every paint. Keyed by id (width- - // independent) — the thumbnail bins this at whatever width, the snap scans it directly. + // parse. Every failure path caches an empty vector so a broken/missing file is not + // re-decoded on every paint. Keyed by id (width-independent) — the thumbnail bins this at + // whatever width, the snap scans it directly. std::string relativePath; std::vector mono; if (processor_) { @@ -308,9 +287,9 @@ const std::vector& ReaSamplerEditor::monoPcmFor(const std::string& if (auto sel = selectSample(*banksJson, sampleId)) relativePath = sel->relativePath; } if (relativePath.empty()) { - // pS fallback: the bank blob is not readable (extension absent / not yet parsed) - // or the id went stale there — the instance-OWNED ref still carries the path, so - // a self-contained instance draws its loaded sound's waveform regardless. + // Fallback: the bank blob is not readable (extension absent / not yet parsed) or + // the id went stale there — the instance-owned ref still carries the path, so a + // self-contained instance draws its loaded sound's waveform regardless. const SampleRefs refs = processor_->sampleRefs(); if (const SelectedSample* r = findRef(refs, sampleId)) { relativePath = r->relativePath; @@ -319,8 +298,7 @@ const std::vector& ReaSamplerEditor::monoPcmFor(const std::string& if (!relativePath.empty()) { const std::string projectDir = processor_->bridge().activeProjectDir(); const std::string abs = resolveBankFile(projectDir, relativePath); - // Shared core/util whole-file loader (Q-W1, T2-03): empty on any failure. - const std::vector bytes = readFileBytes(abs); + const std::vector bytes = readFileBytes(abs); // empty on any failure const WavLayout layout = parseWavLayout(bytes); if (layout.valid) { std::vector interleaved = @@ -334,17 +312,16 @@ const std::vector& ReaSamplerEditor::monoPcmFor(const std::string& } const Envelope& ReaSamplerEditor::thumbnailFor(const std::string& sampleId, int binCount) { - // T2-10 rider: key through the PURE ThumbnailKey (bank_grid) instead of the former - // ad-hoc "id|binCount" concat, so both thumbnail pipelines share one tested key - // grammar (length-prefixed id — collision-proof). The editor invalidates by wholesale - // clear() on refresh/resize, so the bank generation carries no information here — 0. + // Key through the pure ThumbnailKey (bank_grid, length-prefixed id — collision-proof) so + // both thumbnail pipelines share one tested key grammar. The editor invalidates by + // wholesale clear() on refresh/resize, so the bank generation carries no information here. const std::string key = thumbnailKeyString(ThumbnailKey{sampleId, binCount, /*generation=*/0}); auto it = thumbCache_.find(key); if (it != thumbCache_.end()) return it->second; // Bin the (cached) decoded mono PCM at the requested width — one decode per id, reused by - // every thumbnail width AND the S11 waveform surface + snap. + // every thumbnail width AND the waveform surface + snap. const std::vector& mono = monoPcmFor(sampleId); Envelope env; if (!mono.empty()) { diff --git a/src/shell/instrument/processor_reload.cpp b/src/shell/instrument/processor_reload.cpp index 0aa5cff..5130173 100644 --- a/src/shell/instrument/processor_reload.cpp +++ b/src/shell/instrument/processor_reload.cpp @@ -1,11 +1,9 @@ -// processor_reload.cpp — the ReaSamplerProcessor's OFF-AUDIO-THREAD instrument -// lifecycle: reloadInstrument (self-contained refs resolve + WAV decode + keymap -// build), the safety-critical publishBuiltLocked drain-slot swap, the voice-param -// light rebuild, idle-drain retirement, the pre-v10 legacy-lift gate, the S9/S8 -// bank-sync poll, and the pS-usage publish. Split out of reasampler_processor.cpp -// (Q-W2v, T4-12). NOTHING here runs on the audio thread — process() (the lifecycle -// TU) only touches the atomics this family publishes; the atomic-pointer-swap -// pattern deliberately gains NO virtual seam (T4-29). +// processor_reload.cpp — ReaSamplerProcessor's off-audio-thread instrument lifecycle: +// reloadInstrument (self-contained refs resolve + WAV decode + keymap build), the +// safety-critical publishBuiltLocked drain-slot swap, the voice-param light rebuild, +// idle-drain retirement, the pre-v10 legacy-lift gate, the bank-sync poll, and the +// usage publish. Nothing here runs on the audio thread — process() only touches the +// atomics this family publishes; the atomic-pointer-swap pattern gains no virtual seam. #include "shell/instrument/reasampler_processor.h" @@ -18,20 +16,19 @@ #include #include -#include "core/capture/capture_paths.h" // resolveBankFile (shared M4 path resolution) +#include "core/capture/capture_paths.h" // resolveBankFile (shared path resolution) #include "core/capture/wav_codec.h" // parseWavLayout, extractFloatFrames (shared WAV parse) -#include "core/instrument/map/bank_sync.h" // S9/S8 pure decisions: parseBankGeneration, consumeDecision -#include "core/instrument/map/sample_map.h" // refs resolve, buildZonedKeymap (pS self-contained) -#include "core/util/file_bytes.h" // shared whole-file loader (Q-W1, T2-03) -#include "core/wire/assignment_request.h" // decodeAssignmentRequest (S8 request wire parse) -#include "core/wire/sample_usage.h" // pS-usage publish plan + wire (prune-protection seam) +#include "core/instrument/map/bank_sync.h" // pure decisions: parseBankGeneration, consumeDecision +#include "core/instrument/map/sample_map.h" // refs resolve, buildZonedKeymap (self-contained) +#include "core/util/file_bytes.h" // shared whole-file loader +#include "core/wire/assignment_request.h" // decodeAssignmentRequest (request wire parse) +#include "core/wire/sample_usage.h" // usage publish plan + wire (prune-protection seam) #include "ext_keys.h" // kProjExtBanksKey / kProjExtBankGenKey / kProjExtAssignKey namespace reasampler::vst { using namespace instrument::map; // resolution + bank-sync vocabulary this TU drives using namespace reasampler::wire; // assignment_request + sample_usage wire records -// Q-W6 (shim retired): the shared WAV parse + file loader by their real homes. using capture::extractFloatFrames; using capture::parseWavLayout; using capture::resolveBankFile; @@ -40,20 +37,15 @@ using util::readFileBytes; namespace { -// S16 Preserve-mode voice cap: a Preserve voice runs a per-voice OLA pitch shifter and is -// materially heavier than a Varispeed voice. A Preserve note-on past the cap is dropped rather -// than glitching (Varispeed notes are unaffected). Set from the measured/estimated per-voice -// cost — see the handoff CPU note. 8 is conservative pending DAW profiling. Phase S: the -// polyphony bound itself is now the USER-SET voiceCount (1..32, persisted) — this cap stays -// FIXED so raising the voice count never multiplies shifter CPU past the profiled budget. +// Preserve-mode voice cap: a Preserve voice runs a per-voice OLA pitch shifter and is +// materially heavier than a Varispeed voice, so a note-on past the cap is dropped rather +// than glitching. 8 is conservative pending DAW profiling; fixed regardless of the +// user-set voiceCount (1..32) so raising polyphony never multiplies shifter CPU past budget. constexpr std::size_t kPreserveVoiceCap = 8; -// pS-usage: mint a fresh publish identity — 32 lowercase hex chars from the OS entropy -// source. Used for BOTH the persisted per-instance key guid (instanceGuid_) and the -// in-memory per-LIFETIME owner nonce (usageNonce_). Uniqueness (not cryptographic -// strength) is the requirement: two instances sharing a key is the copy-collision -// planUsagePublish resolves fail-safe anyway; the mint just makes accidental collision -// vanishingly unlikely. Off-thread only. +// Mints a fresh publish identity (32 lowercase hex chars) for either the persisted +// instanceGuid_ or the in-memory usageNonce_. Uniqueness, not cryptographic strength, is +// the requirement — planUsagePublish resolves a collision fail-safe anyway. std::string mintUsageInstanceGuid() { std::random_device rd; std::mt19937_64 gen((static_cast(rd()) << 32) ^ rd()); @@ -65,17 +57,11 @@ std::string mintUsageInstanceGuid() { return std::string(buf); } -// Whole-file reads go through the shared core/util readFileBytes (Q-W1, T2-03). -// Off-thread only (blocking file I/O). Empty on any failure — the caller treats -// an unreadable WAV as "nothing to play". - -// Resolve a project-relative WAV path (the M4 way persist does), read + decode it (file -// I/O — off-thread only), and apply the S7 cross-mode channel policy for `mode`: mono mode -// downmixes to one channel (existing policy); stereo mode yields two channels (dual-mono for -// a mono source, L/R for a stereo source) — see decodeChannels. Returns nullopt when the path -// fails to resolve, the file is unreadable, the WAV is malformed, or the decode yields no -// frames — the caller drops the zone (zoned map) or plays silence (single capture). Shared by -// the zoned build and the single-capture path so both decode identically for the active mode. +// Resolves a project-relative WAV path, reads + decodes it (file I/O, off-thread only), +// and applies the cross-mode channel policy for `mode` (mono downmix; stereo -> dual-mono +// for a mono source, L/R for a stereo source — see decodeChannels). Returns nullopt on any +// resolve/read/decode failure — the caller drops the zone or plays silence. Shared by the +// zoned build and the single-capture path. std::optional decodeRelative(const std::string& projectDir, const std::string& relativePath, ChannelMode mode) { @@ -95,22 +81,19 @@ std::optional decodeRelative(const std::string& projectDir, } // namespace std::string ReaSamplerProcessor::reloadInstrument() { - // OFF THE AUDIO THREAD. Serialize concurrent reloads (editor click + setState) so - // the retired-slot free is single-writer. This mutex is NEVER taken on the audio - // thread — process() only touches the atomic. + // OFF THE AUDIO THREAD. Serializes concurrent reloads (editor click + setState) so the + // retired-slot free is single-writer; never taken on the audio thread. std::lock_guard lock(reloadMutex_); - // Mint this reload's generation number first so we can stamp the built instrument - // with it before publishing. Under reloadMutex_ no other reload races here. + // Mint this reload's generation number first so the built instrument is stamped + // before publishing. const std::uint64_t gen = reloadGeneration_.fetch_add(1, std::memory_order_relaxed) + 1; - // 1. SELF-CONTAINED RESOLUTION (pS). The instance-OWNED refs table is the source of - // truth for what to decode. The live bank blob, WHEN readable, is folded into the - // table first (refreshRefsFromBank) — that is the browser's copy-the-ref-in - // mechanism and the S9 recapture sync in one — but its absence changes NOTHING - // below: a project restored before the extension's PROJEXTSTATE parses (or with - // the extension absent entirely) resolves + plays from the persisted refs. The - // project dir comes from REAPER itself (EnumProjects), not from the extension. + // 1. Self-contained resolution: the instance-owned refs table is the source of truth. + // The live bank blob, when readable, is folded in first (refreshRefsFromBank — the + // browser's copy-the-ref-in + recapture-sync mechanism), but its absence changes + // nothing below — a project restored before PROJEXTSTATE parses (or with the + // extension absent) resolves + plays from the persisted refs. const std::string selId = selectedSampleId(); const PerformanceMap map = performanceMap(); const std::vector ids = referencedSampleIds(selId, map); @@ -120,20 +103,18 @@ std::string ReaSamplerProcessor::reloadInstrument() { bridge_.readReasamplerExtState(kProjExtBanksKey); std::lock_guard rl(refsMutex_); if (banksJson) refreshRefsFromBank(sampleRefs_, *banksJson, ids); - // The LOAD path never prunes the owned table: dropping entries here on a transient - // bank miss could destroy the owned intrinsics of the previous selection — the ONE - // copy that survives with the extension absent. Entries for de-referenced ids stay - // in memory (bounded by in-session browsing); hygiene lives at the PERSIST boundary, - // where getState filters its snapshot via retainRefs to what the instance plays. + // The LOAD path never prunes the owned table: dropping entries on a transient bank + // miss could destroy the owned intrinsics of the previous selection — the ONE copy + // that survives with the extension absent. Hygiene lives at the PERSIST boundary + // (getState filters via retainRefs to what the instance plays). refs = sampleRefs_; // snapshot for the decode below (outside the refs lock) } const std::string projectDir = bridge_.activeProjectDir(); - // The active channel mode (S7) governs how each WAV decodes (mono downmix vs 2-channel). - // Read once under its mutex, off the audio thread, before the decode loop. The single- - // capture branch below may auto-default it (GA) before its decode. + // Governs how each WAV decodes (mono downmix vs 2-channel); the single-capture branch + // below may auto-default it before its decode. ChannelMode mode = channelMode(); - // Phase S: snapshot the voice-system parameters once — they are baked into the built - // engine's construction (the engine's config is immutable; a later change rebuilds). + // Snapshot the voice-system parameters once — baked into the built engine's + // construction (immutable config; a later change rebuilds). int builtVoiceCount = kDefaultVoiceCount; VoiceMode builtVoiceMode = VoiceMode::Poly; MonoTrigger builtMonoTrigger = MonoTrigger::Retrigger; @@ -149,12 +130,10 @@ std::string ReaSamplerProcessor::reloadInstrument() { Keymap km; bool haveKeymap = false; - // 2. Tier 1 first: if the instrument's performance map is non-empty, resolve its - // zones against the OWNED refs (an id with no ref drops cleanly), decode each - // zone's WAV off-thread, and build the ZONED keymap. Each surviving zone plays - // its sample repitched from its effective root note (override > ref intrinsic > - // C4). A zone whose WAV fails to decode — a MISSING FILE included — is dropped - // (not the whole map): the defined no-play, no crash, no retry loop. + // 2. Zoned build: if the performance map is non-empty, resolve its zones against the + // owned refs (an id with no ref drops cleanly), decode each zone's WAV off-thread, + // and build the keymap. A zone whose WAV fails to decode is dropped, not the whole + // map — the defined no-play, no crash, no retry loop. if (!map.empty()) { const ResolvedPerformance resolved = resolvePerformanceFromRefs(refs, map); if (!resolved.zones.empty()) { @@ -174,19 +153,15 @@ std::string ReaSamplerProcessor::reloadInstrument() { } } - // 3. Single-capture fast path (S10): an empty performance map plays the ONE - // deliberately-selected capture chromatically across the whole keyboard, resolved - // against the OWNED refs. NO first-sample fallback: an EMPTY selection (or a - // selection with no ref) resolves to nothing, so an un-picked instrument stays - // SILENT (the editor shows its "pick a capture" empty state) rather than - // auto-playing sample #1 (S10 policy reversal of the S4 convenience default). + // 3. Single-capture fast path: an empty performance map plays the one selected capture + // chromatically across the whole keyboard. No first-sample fallback: an empty + // selection (or one with no ref) resolves to nothing, so an un-picked instrument + // stays silent rather than auto-playing sample #1. if (!haveKeymap) { if (const SelectedSample* sel = findRef(refs, selId)) { - // GA auto-default: channelModeFor computes the mode from the loaded capture's - // REQUESTED channel count (always 2 for extension captures; mono only for - // ingest-imported mono files). An unknown count (0) or explicit user choice - // returns the current mode unchanged. Decode-only: the output bus is fixed - // stereo, so no bus work follows a flip. + // Auto-default: channelModeFor computes the mode from the loaded capture's + // channel count (always 2 for extension captures; mono only for ingest-imported + // mono files). An unknown count (0) or explicit user choice keeps the mode. { std::lock_guard cm(channelModeMutex_); channelMode_ = channelModeFor(sel->channelCount, channelMode_, @@ -206,10 +181,9 @@ std::string ReaSamplerProcessor::reloadInstrument() { } if (haveKeymap) { - // Preserve OLA window in OUTPUT frames from the host sample rate (kPreserveWindowMs). - // Every voice's shifter is pre-sized to this off-thread here, so process()-time - // note-on never allocates. Floored at 2 so a valid window is always a real ring - // (which also covers a pathological host rate <= 0 — no rate literal needed). + // Preserve OLA window in output frames from the host rate (kPreserveWindowMs), + // pre-sized here so process()-time note-on never allocates. Floored at 2 so a + // valid window is always a real ring, covering a pathological host rate <= 0 too. std::int64_t preserveWindow = static_cast( kPreserveWindowMs * sampleRate_ / 1000.0 + 0.5); if (preserveWindow < 2) preserveWindow = 2; @@ -218,21 +192,14 @@ std::string ReaSamplerProcessor::reloadInstrument() { kPreserveVoiceCap, preserveWindow, builtVoiceMode, builtMonoTrigger); } - // 4. Publish. Atomically install the new instrument; the DISPLACED one moves into the - // DRAIN slot (FA1, bug 3b) where process() keeps rendering its ringing voices — - // a reload never cuts a sounding note; the next note-on plays the new state. The - // instrument evicted FROM the drain slot (two reloads old) goes to the graveyard - // (process may still be mid-block reading it). A null `built` (no ref / unreadable - // WAV) installs silence while the displaced tails still ring out via the drain. - // `built` is heap-owned; release() hands ownership to the atomic; the drain-evicted - // pointer is re-owned by the graveyard. + // 4. Publish: atomically install the new instrument via the drain-slot swap (see the + // header). A null `built` (no ref / unreadable WAV) installs silence while any + // displaced tails still ring out via the drain. publishBuiltLocked(std::move(built)); - // 5. pS-usage: publish this instance's held captures so the extension's prune can - // never reclaim them (see publishUsage). AFTER the instrument swap, still off the - // audio thread and under reloadMutex_. Publishes regardless of decode success: - // the holds are the refs the instance RETAINS (its play-set), not what decoded — - // a transiently unreadable WAV must stay protected. + // 5. Publish this instance's held captures so the extension's prune can never reclaim + // them. Regardless of decode success: the holds are the refs the instance retains + // (its play-set), not what decoded — a transiently unreadable WAV stays protected. publishUsage(refs, ids); return resolvedId; } @@ -252,15 +219,13 @@ void ReaSamplerProcessor::publishUsage(const SampleRefs& refs, } std::lock_guard lock(usageMutex_); - // A never-published instance with nothing held writes nothing — no key litter for - // fresh/empty instances. Once an identity exists, empties DO publish (they release - // holds the prune would otherwise keep protecting). + // A never-published instance with nothing held writes nothing (no key litter); once an + // identity exists, empties do publish (releasing protected holds). if (instanceGuid_.empty() && mine.holds.empty()) return; if (instanceGuid_.empty()) instanceGuid_ = mintUsageInstanceGuid(); - // The per-LIFETIME owner nonce rides INSIDE the wire (UsageRecord.ownerNonce) so - // planUsagePublish can prove "exactly this incarnation wrote the key" — a same-track - // sibling's byte-identical hold set can never pass as ours (its nonce differs), so - // siblings always union and never clean-replace over each other's held paths. + // The per-lifetime owner nonce (UsageRecord.ownerNonce) lets planUsagePublish prove + // "exactly this incarnation wrote the key" — a same-track sibling's byte-identical hold + // set can never pass as ours, so siblings always union rather than clean-replace. if (usageNonce_.empty()) usageNonce_ = mintUsageInstanceGuid(); mine.ownerNonce = usageNonce_; @@ -268,10 +233,9 @@ void ReaSamplerProcessor::publishUsage(const SampleRefs& refs, bridge_.readReasamplerExtState(usageKeyFor(instanceGuid_)); const UsagePublishPlan plan = planUsagePublish(existing, mine); if (plan.remint) { - // This state was cloned onto another track (FX copy / track duplication): take a - // fresh identity and leave the original's record untouched. The abandoned old - // identity's record dies by the extension's liveness rule when its track no - // longer hosts an instance. getState persists the new guid on the next save. + // Cloned onto another track (FX copy / track duplication): take a fresh identity; + // the abandoned old record dies by the extension's liveness rule once its track no + // longer hosts an instance. instanceGuid_ = mintUsageInstanceGuid(); } else if (plan.skipWrite) { return; // idle tick, or a union that adds nothing — no ext-state churn @@ -280,15 +244,8 @@ void ReaSamplerProcessor::publishUsage(const SampleRefs& refs, } void ReaSamplerProcessor::publishBuiltLocked(std::unique_ptr built) { - // REQUIRES reloadMutex_ held (single-writer over both slots + the graveyard). Shared by - // reloadInstrument and rebuildVoiceEngine — the one safety-critical swap dance. - // - // Bounded reclaim: free graveyard entries whose installedAt < seen, where seen is - // the minimum installedAt process() published over the pointers it holds. Both - // slots are monotone in installedAt, so seen is monotone and any future process() - // load yields installedAt >= seen — an entry below seen is provably unreachable - // (see the header proof). Remaining entries drain at setActive(false) / terminate() - // when process is guaranteed stopped. + // REQUIRES reloadMutex_ held. Shared by reloadInstrument and rebuildVoiceEngine — the + // one safety-critical swap dance (see the header's drain-slot proof). const std::uint64_t seen = processGeneration_.load(std::memory_order_acquire); graveyard_.erase( std::remove_if(graveyard_.begin(), graveyard_.end(), @@ -302,10 +259,9 @@ void ReaSamplerProcessor::publishBuiltLocked(std::unique_ptr b } void ReaSamplerProcessor::rebuildVoiceEngine() { - // OFF THE AUDIO THREAD (the editor's voice-deck click handlers). See the header contract: - // a voice-param change touches NO audio data, so this rebuilds the engine - // around a COPY of the live instrument's already-decoded keymap — no bridge, no disk — - // and publishes through the same drain-slot swap, so ringing tails survive. + // Off the audio thread. A voice-param change touches no audio data, so this rebuilds + // the engine around a copy of the live instrument's already-decoded keymap — no + // bridge, no disk — and publishes through the same drain-slot swap. std::lock_guard lock(reloadMutex_); LoadedInstrument* cur = live_.load(std::memory_order_acquire); if (!cur) return; // nothing loaded: the new params bake into the next real reload. @@ -321,13 +277,14 @@ void ReaSamplerProcessor::rebuildVoiceEngine() { } const std::uint64_t gen = reloadGeneration_.fetch_add(1, std::memory_order_relaxed) + 1; - // Same Preserve-window derivation as reloadInstrument (kPreserveWindowMs at the host rate). + // Same Preserve-window derivation as reloadInstrument. std::int64_t preserveWindow = static_cast( kPreserveWindowMs * sampleRate_ / 1000.0 + 0.5); if (preserveWindow < 2) preserveWindow = 2; - // Deep-copy the decoded PCM + zones. Safe to read concurrently with process(): the keymap - // is immutable after construction, and under reloadMutex_ nobody can free `cur`. + // Deep-copy the decoded PCM + zones: safe to read concurrently with process() because + // the keymap is immutable after construction and reloadMutex_ prevents `cur` from + // being freed. Keymap km = cur->keymap; auto built = std::make_unique( std::move(km), static_cast(builtVoiceCount), gen, @@ -336,23 +293,19 @@ void ReaSamplerProcessor::rebuildVoiceEngine() { } void ReaSamplerProcessor::retireIdleDrain() { - // Phase S (FA1-review Major #2). Cheap early-out BEFORE the lock: 0 means "no drain, or - // it still sounds" — the common case costs one relaxed load and no mutex. + // Cheap early-out before the lock: 0 means "no drain, or it still sounds". const std::uint64_t idleGen = drainIdleGeneration_.load(std::memory_order_acquire); if (idleGen == 0) return; std::lock_guard lock(reloadMutex_); LoadedInstrument* drain = draining_.load(std::memory_order_acquire); - // Retire ONLY if the publication names the drain currently in the slot. A stale value - // (about an already-evicted, older drain) can never match the newer occupant's - // installedAt — the slot is monotone in generation — so a mid-swap race is closed by - // this identity check, not by timing. + // Retire only if the publication names the drain currently in the slot — a stale value + // (an already-evicted, older drain) can never match the newer occupant's installedAt + // (monotone in generation), closing a mid-swap race by identity rather than timing. if (!drain || drain->installedAt != idleGen) return; draining_.store(nullptr, std::memory_order_release); graveyard_.push_back(std::unique_ptr(drain)); - // Prune what is now provably unreachable — the same monotone-generation proof as the - // reload path's reclaim (see reloadInstrument): an entry with installedAt < seen cannot be - // held by process() now or ever again. The just-parked drain frees here immediately when - // process() has already published past it; otherwise on the next reload/retire/deactivate. + // Prune what is now provably unreachable (same monotone-generation proof as + // reloadInstrument's reclaim). const std::uint64_t seen = processGeneration_.load(std::memory_order_acquire); graveyard_.erase( std::remove_if(graveyard_.begin(), graveyard_.end(), @@ -363,18 +316,16 @@ void ReaSamplerProcessor::retireIdleDrain() { } bool ReaSamplerProcessor::legacyLiftShouldRun() { - // #A terminating guard for the pre-v10 legacy lift. The caller has already established - // refs-empty + intent; this decides whether a lift attempt can MAKE PROGRESS before - // paying for a full reload. Once concluded, the steady state is this one relaxed load — - // no bank read, no parse, no reload churn. + // Terminating guard for the pre-v10 legacy lift (caller has already established + // refs-empty + intent). Once concluded, the steady state is one relaxed load — no bank + // read, no parse, no reload churn. if (legacyLiftConcluded_.load(std::memory_order_relaxed)) return false; const LegacyLiftDecision decision = legacyLiftDecision( bridge_.readReasamplerExtState(kProjExtBanksKey), referencedSampleIds(selectedSampleId(), performanceMap())); if (decision == LegacyLiftDecision::Stale) { - // Provably stale (the bank parses and knows none of the referenced ids): give up - // PERMANENTLY. A later bank change that re-introduces an id bumps the generation, - // and the genChanged reload refreshes the refs without consulting this latch. + // Provably stale: give up permanently. A later bank change that re-introduces an + // id bumps the generation, and genChanged refreshes the refs without this latch. legacyLiftConcluded_.store(true, std::memory_order_relaxed); return false; } @@ -383,21 +334,18 @@ bool ReaSamplerProcessor::legacyLiftShouldRun() { ReaSamplerProcessor::BankSyncResult ReaSamplerProcessor::pollBankSync(bool isFocusedTarget) { - // OFF THE AUDIO THREAD (the editor's UI timer calls this). Both reads allocate and call - // REAPER via the bridge — never invoked from process(). A disconnected bridge (non-REAPER - // host, or before connect) yields nullopt for both reads, so this no-ops cleanly. + // Off the audio thread (editor's UI timer only). A disconnected bridge yields nullopt + // for both reads, so this no-ops cleanly. BankSyncResult result; - // Phase S: park an idle drain snapshot in the graveyard (and prune) on the same UI-timer - // cadence that drives reloads — an edited-away instrument stops costing memory as soon - // as its tails die instead of squatting in the drain slot until the next reload. + // Park an idle drain snapshot in the graveyard on the same cadence that drives + // reloads, so an edited-away instrument stops costing memory as soon as tails die. retireIdleDrain(); - // --- S8: assignment-request consume FIRST ------------------------------------- - // Decode the pending assignment request (nullopt when absent/malformed). Resolve its - // (bankId, sampleId) against the live bank blob: selectSample returns non-nullopt only when - // the sampleId names an existing sample (the reader requirement — an unresolvable pair is - // dropped). Then run the pure consume decision against this instance's persisted marker. + // --- Assignment-request consume first ------------------------------------------- + // Decodes the pending assignment request (nullopt if absent/malformed), resolves its + // sampleId against the live bank blob (an unresolvable pair is dropped), then runs the + // pure consume decision against this instance's persisted marker. std::optional request; if (auto raw = bridge_.readReasamplerExtState(kProjExtAssignKey)) { request = decodeAssignmentRequest(*raw); @@ -405,26 +353,24 @@ ReaSamplerProcessor::pollBankSync(bool isFocusedTarget) { bool resolves = false; if (request) { - // Resolve the assigned sample against the CURRENT bank blob (a fresh read, so a request - // whose sample was rolled back by an extension undo resolves to nullopt -> dropped). + // Resolve against the CURRENT bank blob (a fresh read, so a request whose sample + // was rolled back by an extension undo resolves to nullopt -> dropped). if (auto banksJson = bridge_.readReasamplerExtState(kProjExtBanksKey)) { resolves = selectSample(*banksJson, request->sampleId).has_value(); } } - // Read lastConsumed and conditionally write it back under a single lock scope so there - // is no interleave window between the read and the write (a concurrent getState could - // otherwise observe a stale marker between the two separate lock acquisitions). + // Read + conditionally write lastConsumed under one lock scope so a concurrent + // getState cannot observe a stale marker between two separate acquisitions. std::int64_t lastConsumed = 0; const AssignConsumeDecision decision = [&] { std::lock_guard lock(assignMarkerMutex_); lastConsumed = lastConsumedAssignGeneration_; const AssignConsumeDecision d = consumeDecision(request, lastConsumed, resolves, isFocusedTarget); - // Advance the persisted consumed marker whenever the decision consumed the request - // (applied OR dropped-as-seen). getState will persist it on the next project save so - // a re-open does not re-apply. A non-target instance leaves the marker (decision - // returns it unchanged) so it stays eligible if focus later lands here. + // Advance the persisted marker whenever the decision consumed the request + // (applied or dropped-as-seen); a non-target instance leaves it unchanged so it + // stays eligible if focus later lands here. if (d.consumedGeneration != lastConsumed) { lastConsumedAssignGeneration_ = d.consumedGeneration; } @@ -432,13 +378,12 @@ ReaSamplerProcessor::pollBankSync(bool isFocusedTarget) { }(); if (decision.apply) { - // Apply the assignment as this instance's own selection (the same path a user card-pick - // takes) — the instrument updates its OWN state, never the bank. reloadInstrument below - // rebuilds against the new selection, so skip a redundant reload here. + // Apply as this instance's own selection (the instrument updates its own state, + // never the bank); reloadInstrument below rebuilds against it. setSelectedSampleId(decision.sampleId); - // Zone-bleed fix (3a), peer of the editor's Browse Load: a stale full-range zone - // materialized for the previously loaded sample would shadow the assigned pick under - // first-match resolve. Authored maps (any narrow key range) are untouched. + // Peer of the editor's Browse Load: a stale full-range zone from the previous + // sample would shadow the assigned pick under first-match resolve. Authored maps + // (narrow key ranges) are untouched. PerformanceMap reconciled = performanceMap(); if (reconcileSingleCaptureZones(reconciled, decision.sampleId)) { setPerformanceMap(reconciled); @@ -446,13 +391,10 @@ ReaSamplerProcessor::pollBankSync(bool isFocusedTarget) { result.applied = true; } - // --- S9: bank-generation change-detection ------------------------------------- - // Read the generation stamp; parse (absent/malformed -> 0, the pre-S9 default). FIRST poll - // (lastSeenBankGeneration_ == -1 sentinel): BASELINE the seen value without a reload — - // setState already loaded the instrument from its OWNED refs (pS), so a redundant reload - // on open would only churn. A later - // generation CHANGE (a recapture/ingest/remove, or an undo that lowers it) then drives the - // reload. An assignment we just applied also needs a reload; fold both into ONE (coalesced). + // --- Bank-generation change-detection ------------------------------------------- + // First poll (lastSeenBankGeneration_ == -1 sentinel) baselines without a reload — + // setState already loaded from owned refs, so a redundant reload on open would only + // churn. A later generation change (recapture/ingest/remove/undo) drives the reload. std::int64_t currentGen = kBankGenerationAbsent; if (auto rawGen = bridge_.readReasamplerExtState(kProjExtBankGenKey)) { currentGen = parseBankGeneration(*rawGen); @@ -462,18 +404,12 @@ ReaSamplerProcessor::pollBankSync(bool isFocusedTarget) { !firstPoll && bankGenerationChanged(lastSeenBankGeneration_, currentGen); lastSeenBankGeneration_ = currentGen; - // LEGACY LIFT (pre-v10 blob): the restored state carries intent (a selection or zones) - // but NO owned refs — a pre-pS blob had no path table, so the setState-time reload had - // nothing to decode unless the bank happened to be readable already. Reload on this - // editor tick until the lift lands: reloadInstrument folds the bank blob into the refs - // when readable, after which the table is non-empty and this never fires again (the - // next save is then self-contained). A deliberately-empty instance has no intent and - // never churns; a bank that is not readable YET retries a cheap null publish on the - // editor cadence only. TERMINATING GUARD (#A, legacyLiftShouldRun): once the bank blob - // PARSES and no referenced id resolves in it, the ids are provably stale — there is - // nothing to lift, so the lift concludes permanently instead of churning a full bank - // read + reload every tick forever. This is a MIGRATION convenience for old projects, - // NOT a playback dependency — a v10 blob plays from its refs with no poll at all (pS). + // Legacy lift (pre-v10 blob): restored state carries intent but no owned refs (old + // blobs had no path table). Reload on this tick until reloadInstrument folds the bank + // blob into the refs (after which this never fires again — the next save is + // self-contained). legacyLiftShouldRun concludes permanently once the bank parses and + // no referenced id resolves — a migration convenience only, never a playback + // dependency (a v10 blob plays from its refs with no poll at all). bool legacyLift = false; if (!genChanged && !result.applied && sampleRefs().empty()) { const bool hasIntent = !selectedSampleId().empty() || !performanceMap().empty(); @@ -481,10 +417,9 @@ ReaSamplerProcessor::pollBankSync(bool isFocusedTarget) { } if (genChanged || result.applied || legacyLift) { - reloadInstrument(); // atomic pointer-swap handoff — glitch-free mid-play (S4 graveyard) - // Report the reload distinctly from an S8 apply so the editor re-snapshots its bank - // view. A legacy lift counts only when it actually landed an instrument (otherwise - // every retry tick would churn the editor's caches for nothing). + reloadInstrument(); // atomic pointer-swap handoff — glitch-free mid-play + // Reported distinctly from an applied assignment so the editor re-snapshots its + // bank view; a legacy lift counts only when it actually landed an instrument. result.reloaded = genChanged || (legacyLift && live_.load(std::memory_order_acquire) != nullptr); diff --git a/src/shell/instrument/processor_state.cpp b/src/shell/instrument/processor_state.cpp index 5f8bbe1..ab45a2d 100644 --- a/src/shell/instrument/processor_state.cpp +++ b/src/shell/instrument/processor_state.cpp @@ -1,10 +1,9 @@ -// processor_state.cpp — the ReaSamplerProcessor's COMPONENT-STATE I/O (setState / -// getState against the component_state_io codec) and its UI-thread parameter -// accessors/setters (selection, performance map, channel mode, preview velocity, -// voice-system params, master gain, preview-note mailbox posts). Split out of -// reasampler_processor.cpp (Q-W2v, T4-12). Everything here runs OFF the audio -// thread (UI / host load-save); the setters hand work to the reload family -// (processor_reload.cpp) or store atomics process() picks up at block start. +// processor_state.cpp — ReaSamplerProcessor's component-state I/O (setState/getState +// against the component_state_io codec) and its UI-thread parameter accessors/setters +// (selection, performance map, channel mode, preview velocity, voice-system params, +// master gain, preview-note mailbox posts). Everything here runs off the audio thread; +// setters hand work to the reload family (processor_reload.cpp) or store atomics +// process() picks up at block start. #include "shell/instrument/reasampler_processor.h" @@ -14,8 +13,8 @@ #include "pluginterfaces/base/ibstream.h" -#include "core/instrument/engine/master_gain.h" // masterGainMaxLinear (FB1 post-mixer gain clamp) -#include "core/instrument/map/component_state_io.h" // the ComponentState codec (Q-W2v split) +#include "core/instrument/engine/master_gain.h" // masterGainMaxLinear (post-mixer gain clamp) +#include "core/instrument/map/component_state_io.h" // the ComponentState codec #include "core/instrument/map/sample_map.h" // reconcileSingleCaptureZones / retainRefs / referencedSampleIds using namespace Steinberg; @@ -24,135 +23,107 @@ using namespace Steinberg::Vst; namespace reasampler::vst { using namespace instrument::map; // the codec + resolution vocabulary this TU marshals -using instrument::engine::masterGainMaxLinear; // FB1 taper ceiling (Q-W6: shim retired) +using instrument::engine::masterGainMaxLinear; // taper ceiling tresult PLUGIN_API ReaSamplerProcessor::setState(IBStream* state) { if (!state) return kResultFalse; - // Read the whole component-state blob (the performance map, versioned). The blob is - // small; read in one shot into a growable buffer. + // The blob is small; read it in one shot into a growable buffer. std::vector bytes; std::uint8_t chunk[256]; int32 got = 0; while (state->read(chunk, sizeof(chunk), &got) == kResultOk && got > 0) { bytes.insert(bytes.end(), chunk, chunk + got); } - // Component state (v3, S10) is {single-capture selection id, opt-in zones}. The - // selection and the zones are DISTINCT — the default face is one picked capture, zones - // are a demoted overlay — so both are restored explicitly (no more inferring a selection - // from a lone zone). deserializeComponentState lifts older blobs cleanly: a v2 zones-only - // blob restores {"", zones}; a v1 S4 single-selection blob restores {id, one-zone map} so - // the old pick survives as both; an empty/unknown blob restores {"", no zones} — the S10 - // silent empty state (no first-sample fallback in reloadInstrument). - // Pass sampleRate_ as the project rate for legacy v3 blob conversion (frames -> seconds at - // the v3 read boundary). sampleRate_ is set by setupProcessing; REAPER calls setupProcessing - // before setState on project load, so sampleRate_ is the real host rate here. A v3 blob on a - // pre-setup call would assert inside readZonesPayload (a programming error, not a field case). + // Component state is {single-capture selection id, opt-in zones}, restored explicitly + // since they're distinct (default face vs. a demoted overlay). deserializeComponentState + // lifts older blobs cleanly (no first-sample fallback in reloadInstrument). sampleRate_ + // is the real host rate here — REAPER calls setupProcessing before setState on load. const ComponentState cs = deserializeComponentState(bytes, sampleRate_); setSelectedSampleId(cs.selectionId); - // Zone-bleed fix (3a) heal-on-load: a blob saved under the pre-fix editor may carry a - // pile of stale full-range zones (one per sample ever browsed), the oldest shadowing the - // saved selection under first-match resolve. Reconciling here restores "the sample the - // editor shows is the sample the engine plays" for already-affected projects; authored - // Zone-view maps (any narrow key range) pass through untouched. + // Heal-on-load: a blob saved under the pre-fix editor may carry stale full-range zones + // (one per sample ever browsed), the oldest shadowing the saved selection under + // first-match resolve. Authored Zone-view maps (narrow key ranges) pass through untouched. PerformanceMap restored = cs.map; - reconcileSingleCaptureZones(restored, cs.selectionId); // bool return ignored: setPerformanceMap + reloadInstrument run unconditionally on load + reconcileSingleCaptureZones(restored, cs.selectionId); // bool return ignored: reload below runs unconditionally setPerformanceMap(restored); - // S8: restore the last-consumed assignment generation so a re-open does not re-apply a - // stale assign_request (the user may have manually changed the selection after the assign). + // Restore the last-consumed assignment generation so a re-open does not re-apply a + // stale assign_request. { std::lock_guard lock(assignMarkerMutex_); lastConsumedAssignGeneration_ = cs.lastConsumedAssignGeneration; } - // Restore the S7 channel mode + the GA explicit flag. The output bus is FIXED stereo (see - // initialize) — the mode only governs how the reload below decodes, so no bus work here. + // The output bus is fixed stereo (see initialize) — the mode only governs decode below. { std::lock_guard lock(channelModeMutex_); channelMode_ = cs.channelMode; channelModeExplicit_ = cs.channelModeExplicit; } - // S-VIEW-4: restore the per-instance preview velocity. Guarded by previewMutex_ — since Wave 2 - // the editor's velocity knob is a concurrent UI-thread writer. { std::lock_guard lock(previewMutex_); previewVelocity_ = cs.previewVelocity; } - // Phase S: restore the voice-system parameters (v7; older blobs lift to {16, Poly, - // Retrigger} in deserializeComponentState — pre-Phase-S behavior). Restored BEFORE the - // reload below so the rebuilt engine is born with the saved polyphony/mode. + // Restore before the reload so the rebuilt engine is born with the saved polyphony/mode. { std::lock_guard lock(voiceParamsMutex_); voiceCount_ = cs.voiceCount; voiceMode_ = cs.voiceMode; monoTrigger_ = cs.monoTrigger; } - // FB1: restore the post-mixer master gain (v8; older blobs lift to unity in - // deserializeComponentState — pre-FB1 output). One atomic store; the audio thread picks - // it up at the next block start. setMasterGainLinear(cs.masterGainLinear); - // pS self-contained playback: restore the instance-OWNED sample refs (v10) BEFORE the - // reload so it decodes straight from them — no bank read required to play. A pre-v10 - // blob lifts to an EMPTY table; the reload then resolves nothing until the bank blob - // becomes readable (the reload's opportunistic refresh, or pollBankSync's legacy lift), - // after which the next save is self-contained. + // Restore the instance-owned sample refs before the reload so it decodes straight from + // them — no bank read required. A pre-v10 blob lifts to an empty table; the reload + // resolves nothing until the bank blob becomes readable (opportunistic refresh, or + // pollBankSync's legacy lift), after which the next save is self-contained. { std::lock_guard lock(refsMutex_); sampleRefs_ = cs.sampleRefs; } - // pS-usage: restore the persisted publish identity (v11; pre-v11 lifts to empty — - // minted on first publish). usageNonce_ resets: a restored blob is a NEW LIFETIME - // for the copy-collision analysis (the fresh nonce means this incarnation can never - // be mistaken for the previous one's writes — or for a copy-sibling's). + // Restore the publish identity (pre-v11 lifts to empty, minted on first publish). + // usageNonce_ resets: a restored blob is a new lifetime, so this incarnation can never + // be mistaken for the previous one's writes or a copy-sibling's. { std::lock_guard lock(usageMutex_); instanceGuid_ = cs.instanceGuid; usageNonce_.clear(); } - // A new blob is new facts: a staleness proof latched against the PREVIOUS state does - // not carry over (#A — the legacy lift gets one fresh run per restored state). + // A new blob is new facts — the legacy lift gets one fresh run per restored state. legacyLiftConcluded_.store(false, std::memory_order_relaxed); - // Rebuild from the restored state (off-thread — setState is a load-time call). reloadInstrument(); return kResultOk; } tresult PLUGIN_API ReaSamplerProcessor::getState(IBStream* state) { if (!state) return kResultFalse; - // Persist the full instance state (v3, S10): the single-capture selection id AND the - // opt-in zones — the instrument's own state (D-B), NEVER written to the "reasampler" - // bank ext-state. An instance with no pick and no zones serializes to {"", no zones} - // and restores as the S10 empty state (silence + "pick a capture"), never auto-playing - // sample #1. + // Persists the full instance state — never written to the "reasampler" bank ext-state. + // No pick + no zones serializes to {"", no zones}, restoring as silence (never + // auto-playing sample #1). ComponentState state_out; state_out.selectionId = selectedSampleId(); state_out.map = performanceMap(); { - // S7: persist the per-instance mono/stereo decode mode + the GA explicit flag (v9). std::lock_guard lock(channelModeMutex_); state_out.channelMode = channelMode_; state_out.channelModeExplicit = channelModeExplicit_; } { std::lock_guard lock(assignMarkerMutex_); - state_out.lastConsumedAssignGeneration = lastConsumedAssignGeneration_; // S8 reader marker + state_out.lastConsumedAssignGeneration = lastConsumedAssignGeneration_; } - state_out.previewVelocity = previewVelocity(); // S-VIEW-4: persist the preview strike velocity + state_out.previewVelocity = previewVelocity(); { - // Phase S: persist the voice-system parameters (component state v7). std::lock_guard lock(voiceParamsMutex_); state_out.voiceCount = voiceCount_; state_out.voiceMode = voiceMode_; state_out.monoTrigger = monoTrigger_; } - state_out.masterGainLinear = masterGainLinear(); // FB1: persist the post-mixer gain (v8) - // pS: persist the OWNED sample refs (v10) — the saved blob carries everything needed to - // decode + play with no extension present. Filtered (on the snapshot copy, the member is - // untouched) to exactly what the instance currently plays, so the table cannot grow with - // browsing history. + state_out.masterGainLinear = masterGainLinear(); + // Persist the owned sample refs — the saved blob decodes + plays with no extension + // present. Filtered (snapshot copy only) to what the instance currently plays, so the + // table cannot grow with browsing history. state_out.sampleRefs = sampleRefs(); retainRefs(state_out.sampleRefs, referencedSampleIds(state_out.selectionId, state_out.map)); - // pS-usage: persist the publish identity (v11) so the instance's usage key is - // stable across sessions (records do not proliferate per reopen). + // Persist the publish identity so the usage key is stable across sessions. { std::lock_guard lock(usageMutex_); state_out.instanceGuid = instanceGuid_; @@ -202,8 +173,7 @@ std::uint8_t ReaSamplerProcessor::previewVelocity() { } void ReaSamplerProcessor::setPreviewVelocity(std::uint8_t velocity) { - // Clamp to the MIDI-note range [1,127] (0 would be a note-off by convention — a preview - // strike must sound). The editor's knob maps its 0..1 domain into this range before calling. + // Clamp to [1,127] — 0 would be a note-off by convention, and a preview strike must sound. if (velocity < 1) velocity = 1; if (velocity > 127) velocity = 127; std::lock_guard lock(previewMutex_); @@ -216,8 +186,8 @@ int ReaSamplerProcessor::voiceCount() { } void ReaSamplerProcessor::setVoiceCount(int count) { - // Clamp to the shared pure-core range so the engine, the state bytes, and the editor's - // control can never disagree about the legal polyphony span. + // Clamp to the shared pure-core range so the engine, state bytes, and editor control + // can never disagree about the legal polyphony span. if (count < kMinVoiceCount) count = kMinVoiceCount; if (count > kMaxVoiceCount) count = kMaxVoiceCount; { @@ -225,11 +195,8 @@ void ReaSamplerProcessor::setVoiceCount(int count) { if (voiceCount_ == count) return; // no-op: don't churn a rebuild voiceCount_ = count; } - // LIGHT rebuild OFF-thread through the drain-slot swap: the engine is reconstructed from - // the already-decoded keymap (no bridge re-read, no WAV re-decode — a polyphony change - // touches no audio data) and the displaced instrument keeps rendering its ringing tails, - // so a voice-param change never cuts a sounding note NOR stalls the UI re-decoding every - // zone from disk. Same contract for the mode/trigger setters below. + // Light rebuild through the drain-slot swap (no bridge re-read, no WAV re-decode) so a + // voice-param change never cuts a sounding tail. Same contract below. rebuildVoiceEngine(); } @@ -262,9 +229,8 @@ void ReaSamplerProcessor::setMonoTrigger(MonoTrigger trigger) { } void ReaSamplerProcessor::setMasterGainLinear(double linear) { - // Clamp to the control's legal span (the master_gain taper: 0 = -inf/silence, cap = - // +24 dB). One relaxed atomic store — the audio thread reads it at the next block start; - // no rebuild, no lock (a post-sum output trim is not a keymap fact). + // Clamp to the master_gain taper (0 = silence, cap = +24 dB). One relaxed atomic + // store — no rebuild, no lock (a post-sum trim is not a keymap fact). if (!(linear >= 0.0)) linear = 0.0; // also catches NaN const double maxLin = masterGainMaxLinear(); if (linear > maxLin) linear = maxLin; @@ -275,9 +241,8 @@ void ReaSamplerProcessor::previewNoteOn(int note) { if (note < 0) note = 0; if (note > 127) note = 127; const std::uint8_t vel = previewVelocity(); // latch the current knob value into the request - // Advance the sequence (wrapping; process compares for inequality, so a wrap is harmless as - // long as we never land back on the exact value the audio thread last consumed in one step — - // 16 bits gives 65535 posts between collisions, unreachable at UI-click rates). + // Advance the sequence (wrapping; process compares for inequality — 16 bits gives 65535 + // posts between collisions, unreachable at UI-click rates). const std::uint16_t seq = ++previewOnSeq_ == 0 ? ++previewOnSeq_ : previewOnSeq_; const std::uint32_t packed = (static_cast(seq) << 16) | (static_cast(vel) << 8) | @@ -297,15 +262,14 @@ void ReaSamplerProcessor::previewNoteOff(int note) { void ReaSamplerProcessor::setChannelMode(ChannelMode mode) { { std::lock_guard lock(channelModeMutex_); - // The editor toggle is a DELIBERATE choice either way: latch explicit even on a - // same-mode click (the user confirmed the mode; the GA auto-default stops fighting it). + // A deliberate choice either way: latch explicit even on a same-mode click so + // auto-default stops fighting it. channelModeExplicit_ = true; if (channelMode_ == mode) return; // no decode change: don't churn a reload channelMode_ = mode; } - // The DECODE policy changed. The output bus is FIXED stereo (GA fix — no bus repoint, no - // restartComponent): reloading re-decodes the loaded WAV(s) under the new mode off-thread - // (mono = downmix, stereo = L/R split) and the RT path just keeps rendering. + // The output bus is fixed stereo (no bus repoint): reloading re-decodes off-thread + // under the new mode and the RT path just keeps rendering. reloadInstrument(); } diff --git a/src/shell/instrument/reaper_bridge.cpp b/src/shell/instrument/reaper_bridge.cpp index 92062df..6415d31 100644 --- a/src/shell/instrument/reaper_bridge.cpp +++ b/src/shell/instrument/reaper_bridge.cpp @@ -5,41 +5,33 @@ #include #include "core/instrument/map/bridge_marshal.h" -#include "core/wire/ext_state_read.h" // readProjExtStateGrowing (T2-04 grow-loop policy) -#include "core/capture/capture_paths.h" // projectDirOfRpp (shared M4 project-dir derivation) +#include "core/wire/ext_state_read.h" // readProjExtStateGrowing (grow-loop policy) +#include "core/capture/capture_paths.h" // projectDirOfRpp (shared project-dir derivation) #include "ext_keys.h" // kProjExtNamespace (shared wire contract) -// The VST3 base types must be included before REAPER's VST3 interface header, which -// uses FUnknown / CStringA / uint32 / DECLARE_CLASS_IID / PLUGIN_API from -// pluginterfaces/base — all in namespace Steinberg. +// VST3 base types must be included before REAPER's VST3 interface header, which uses +// unqualified Steinberg types (FUnknown, CStringA, uint32, DECLARE_CLASS_IID, PLUGIN_API). #include "pluginterfaces/base/funknown.h" #include "pluginterfaces/base/ftypes.h" -// REAPER's VST3-side bridge interface (vendored). IReaperHostApplication is what REAPER -// passes (as an IHostApplication) to IComponent::initialize; it exposes getReaperApi -// (resolve-by-name) and getReaperParent (host context). The header uses UNQUALIFIED -// Steinberg types (FUnknown, CStringA, uint32, FUID, DECLARE_CLASS_IID, PLUGIN_API), so -// it must be pulled into the Steinberg namespace — the same way REAPER's own VST3 -// examples include it. +// REAPER's VST3-side bridge interface (vendored): IReaperHostApplication is the +// IHostApplication REAPER passes to IComponent::initialize, exposing getReaperApi +// (resolve-by-name) and getReaperParent (host context). Pulled into namespace Steinberg +// (the header's unqualified types), the same way REAPER's own VST3 examples include it. namespace Steinberg { #include "reaper_vst3_interfaces.h" } // namespace Steinberg -// DECLARE_CLASS_IID in the REAPER header only DECLARES IReaperHostApplication::iid; some -// TU must DEFINE it. We do it here — this is the only place that queries for the -// interface (FUnknownPtr uses the iid), so the definition lives with its sole use. +// DECLARE_CLASS_IID in the REAPER header only declares the iid; this is the only TU that +// queries for the interface, so the DEFINE lives with its sole use. DEF_CLASS_IID(Steinberg::IReaperHostApplication) -// The ext-state namespace is the SHARED wire contract between the extension (writer) -// and this instrument (reader); it lives in ext_keys.h (pure, REAPER-free) — -// reasampler::kProjExtNamespace() — so the two artifacts read one symbol and cannot -// drift. Channel-derived (Phase V, V4): the accessor returns "reasampler" (stable) or -// "reasampler_beta" (beta), matching whatever the extension wrote. The S1 spike -// duplicated it locally; that duplication is retired. +// The ext-state namespace is the shared wire contract with the extension — ext_keys.h's +// kProjExtNamespace() (pure, REAPER-free), channel-derived so both artifacts read one +// symbol and cannot drift. namespace reasampler::vst { -// Real-namespace-home using-declarations (Q-W6: the namespaces.h shim is retired). using capture::projectDirOfRpp; using instrument::map::decodeGetProjExtState; @@ -53,27 +45,24 @@ bool ReaperBridge::connect(Steinberg::FUnknown* context) { hostApp_ = nullptr; if (!context) return false; - // Query the host context for REAPER's bridge interface. In a non-REAPER host this - // query fails and we stay unconnected — the instrument still loads. + // In a non-REAPER host this query fails and we stay unconnected — the instrument + // still loads. Steinberg::FUnknownPtr reaper(context); if (!reaper) return false; hostApp_ = reaper.get(); - // Resolve the ext-state functions by name. getReaperApi returns the same function - // pointers the extension resolves via rec->GetFunc; a null return means the symbol - // is unavailable (very old REAPER) — degrade gracefully. + // getReaperApi returns the same function pointers the extension resolves via + // rec->GetFunc; a null return means the symbol is unavailable (very old REAPER). getProjExtState_ = reinterpret_cast( reaper->getReaperApi("GetProjExtState")); enumProjExtState_ = reinterpret_cast( reaper->getReaperApi("EnumProjExtState")); - // EnumProjects(-1, ...) yields the active project AND its .rpp path — the same call - // the persist shell (ext_state_io.cpp) uses, so the instrument derives the project - // directory identically. + // EnumProjects(-1, ...) yields the active project + its .rpp path — same convention + // the persist shell uses, so the instrument derives the project directory identically. enumProjects_ = reinterpret_cast( reaper->getReaperApi("EnumProjects")); - // pS-usage: the (prefix-guarded) usage publish write + the track-identity pair the - // usage record stamps. All degrade to null gracefully — an old REAPER just never - // publishes usage (the extension then protects by bank references only). + // The (prefix-guarded) usage publish write + the track-identity pair it stamps. All + // degrade to null gracefully — an old REAPER never publishes usage. setProjExtState_ = reinterpret_cast( reaper->getReaperApi("SetProjExtState")); getTrackGuid_ = reinterpret_cast( @@ -87,23 +76,16 @@ bool ReaperBridge::connect(Steinberg::FUnknown* context) { std::optional ReaperBridge::readReasamplerExtState(const std::string& key) { if (!getProjExtState_ || !hostApp_) return std::nullopt; - // Fetch the host project (getReaperParent(3) — project). Reads that live "reasampler" - // ext-state against the ACTIVE project the instrument was instantiated in, so it - // follows project switches for free (D6). + // getReaperParent(3) reads the live "reasampler" ext-state against the active project + // the instrument was instantiated in, so it follows project switches for free. A null + // project is legitimate (REAPER treats it as the current project) — pass it through + // rather than bailing; a fruitless read still yields nullopt to the caller. auto* reaper = static_cast(hostApp_); void* proj = reaper->getReaperParent(3); - // A null project is legitimate (e.g. instantiated before a project context exists); - // REAPER treats null as the current project for these calls, so we pass it through - // rather than bailing — but if the read yields nothing the caller sees nullopt. - // GetProjExtState writes into a caller buffer; the bank blob can be large (many - // samples), so grow the buffer until the value fits rather than risk a silent - // truncation. The retry policy is the SHARED pure wire::readProjExtStateGrowing - // (T2-04 — one loop for the - // extension's persist/usage reads and this bridge read; the rules cannot drift): - // absent (rv <= 0) and the >16 MB ceiling both fold to nullopt here, and a - // complete value still runs through decodeGetProjExtState (the stale/empty-buffer - // guard) exactly as before. + // The bank blob can be large, so grow the buffer until it fits rather than risk a + // silent truncation. The shared wire::readProjExtStateGrowing loop keeps this bridge + // read and the extension's persist/usage reads from drifting. const auto read = wire::readProjExtStateGrowing( [&](char* buf, int cap) { return getProjExtState_(proj, kProjExtNamespace(), key.c_str(), buf, cap); @@ -116,24 +98,21 @@ std::optional ReaperBridge::readReasamplerExtState(const std::strin bool ReaperBridge::writeUsageExtState(const std::string& usageKey, const std::string& value) { if (!setProjExtState_ || !hostApp_) return false; - // STRUCTURAL read-only-bank guard: this module writes usage keys and nothing else. - // A non-"rsusage_" key is a programming error upstream — refuse rather than widen - // the instrument's write surface (banks/view/tail/assign stay extension-owned). + // Read-only-bank guard: this module writes usage keys and nothing else. A non- + // "rsusage_" key is refused rather than widening the instrument's write surface + // (banks/view/tail/assign stay extension-owned). const std::string prefix = kProjExtUsageKeyPrefix; if (usageKey.compare(0, prefix.size(), prefix) != 0) return false; auto* reaper = static_cast(hostApp_); void* proj = reaper->getReaperParent(3); // null = current project (same as reads) - // SetProjExtState returns "the size of the state for this extname" (SDK ~6288) — - // after storing our non-empty value the namespace state is necessarily > 0, so a - // <= 0 return means the write did not land. Reported to the caller (the publish - // path retries on the next reload tick); a silently-dropped record would leave the - // instance's holds unprotected. + // SetProjExtState returns the size of the extname's state — after storing a + // non-empty value that's necessarily > 0, so <= 0 means the write did not land (the + // publish path retries next reload tick; a silent drop would leave holds unprotected). const int rv = setProjExtState_(proj, kProjExtNamespace(), usageKey.c_str(), value.c_str()); - // Deliberately NO MarkProjectDirty: a usage change always accompanies a component- - // state change that already dirties the project; an idempotent load-time republish - // must not flag an untouched project as modified. + // Deliberately NO MarkProjectDirty: a usage change always rides a component-state + // change that already dirties the project. return rv > 0; } @@ -151,11 +130,8 @@ std::string ReaperBridge::currentTrackGuid() { std::string ReaperBridge::activeProjectDir() { if (!enumProjects_) return {}; - // idx=-1 is the current project tab; the out-buffer receives the full .rpp path, - // EMPTY for a never-saved project. Same call + convention as the persist shell; the pure - // projectDirOfRpp turns the .rpp path into the project directory (parent, forward- - // slashed) and keeps an unsaved project's empty path empty (no default-location - // fallback — the tool's invariant). + // idx=-1 is the current project tab; the out-buffer is empty for a never-saved + // project. projectDirOfRpp keeps that empty (no default-location fallback). std::vector buf(4096, '\0'); enumProjects_(-1, buf.data(), static_cast(buf.size())); return projectDirOfRpp(std::string(buf.data())); diff --git a/src/shell/instrument/reaper_bridge.h b/src/shell/instrument/reaper_bridge.h index 29bf057..5d14bc9 100644 --- a/src/shell/instrument/reaper_bridge.h +++ b/src/shell/instrument/reaper_bridge.h @@ -1,19 +1,11 @@ -// reaper_bridge.h — the REAPER VST-host bridge (Phase S1 read spike). THIN shell: -// resolves REAPER API functions by name over the host context and reads the live -// "reasampler" project ext-state. The fiddly decode lives in bridge_marshal (pure). +// reaper_bridge.h — the REAPER VST-host bridge. Thin shell: resolves REAPER API functions +// by name over the host context and reads the live "reasampler" project ext-state. The +// fiddly decode lives in bridge_marshal (pure). // -// VERIFIED BRIDGE MECHANISM (corrects §1a's estimate). §1a described the VST2-style -// hostcb opcode pattern (hostcb(&effect, 0xdeadbeef, 0xdeadf00d, ...)). That is the -// VST2 path (video_processor.h documents it for a VST2 aEffect). For a VST3 plugin the -// bridge is exposed differently and more cleanly: REAPER passes an IHostApplication as -// the `context` to IComponent::initialize(FUnknown* context); querying it for -// IReaperHostApplication (vendor/reaper-sdk/sdk/reaper_vst3_interfaces.h) yields: -// * getReaperApi(funcname) -> resolve a REAPER API function pointer by name -// (the VST3 equivalent of opcode 0xdeadf00d), and -// * getReaperParent(3) -> the host ReaProject* (the VST3 equivalent of the -// 0xdeadf00e host-context fetch; 1=track, 2=take, 3=project, 4=fxdsp, 5=trackchan). -// So a VST3 uses IReaperHostApplication, not the raw hostcb opcodes. Verified against -// reaper_vst3_interfaces.h + reaper_plugin_functions.h at the spike. +// Bridge mechanism: REAPER passes an IHostApplication as `context` to +// IComponent::initialize; querying it for IReaperHostApplication yields getReaperApi +// (resolve a REAPER API function pointer by name) and getReaperParent(3) (the host +// ReaProject*; 1=track, 2=take, 3=project, 4=fxdsp, 5=trackchan) — not VST2 hostcb opcodes. #pragma once @@ -32,48 +24,39 @@ class ReaperBridge { public: ReaperBridge() = default; - // Bind to the host. `context` is the FUnknown* REAPER hands IComponent::initialize. - // Returns true when the REAPER bridge is available (host is REAPER and the ext-state - // API resolved). Safe to call with a null or non-REAPER context — returns false. + // Binds to the host (`context` is the FUnknown* IComponent::initialize hands us). + // Returns true when the host is REAPER and the ext-state API resolved; safe to call + // with a null or non-REAPER context (returns false). bool connect(Steinberg::FUnknown* context); - // True once connect() found the REAPER host application AND resolved the ext-state - // functions. bool isConnected() const { return getProjExtState_ != nullptr; } - // Read a "reasampler" ext-state value by key from the host's active project. - // Returns nullopt when unconnected, when the project can't be resolved, or when the - // key is absent. This is the S1 read-spike entry point. + // Reads a "reasampler" ext-state value by key from the host's active project. + // Returns nullopt when unconnected, unresolvable, or the key is absent. // - // NOT REAL-TIME SAFE (it allocates a read buffer and calls into REAPER): callers on - // the audio thread MUST NOT invoke it. The S4 instrument reads on the main/UI thread - // and hands a snapshot to the process path (see reasampler_processor.cpp). + // NOT REAL-TIME SAFE (allocates + calls into REAPER): audio-thread callers MUST NOT + // invoke this. The instrument reads on the main/UI thread and hands a snapshot to + // the process path. std::optional readReasamplerExtState(const std::string& key); - // The active project's directory (the folder holding its .rpp), forward-slashed, - // no trailing slash — the M4 convention persist uses to place the bank alongside - // the .rpp. Empty for an unsaved project or when unconnected. The instrument - // resolves relative sample paths against this the SAME way persist does - // (capture_paths::projectDirOfRpp over EnumProjects(-1)'s .rpp path). Not RT-safe. + // The active project's directory (forward-slashed, no trailing slash) — the same + // convention persist uses to place the bank alongside the .rpp. Empty for an unsaved + // project or when unconnected. Not RT-safe. std::string activeProjectDir(); - // Write THIS INSTANCE's usage record (pS-usage): the ONE sanctioned instrument-side - // ext-state write. `usageKey` MUST carry the "rsusage_" prefix (ext_keys.h's - // usageKeyFor) — any other key is REFUSED here, so the read-only-BANK invariant is - // enforced structurally: this module can publish the instance's own usage and - // nothing else (banks/view/tail/assign remain unwritable from the instrument). - // Returns true iff written (the SetProjExtState return is checked — a dropped - // write must not silently claim protection). NOT RT-safe (calls into REAPER) — - // publish sites are the off-audio-thread reload path only. Deliberately does NOT - // mark the project dirty: a usage change always rides a component-state change - // that already does. + // Writes THIS INSTANCE's usage record: the ONE sanctioned instrument-side ext-state + // write. `usageKey` MUST carry the "rsusage_" prefix (ext_keys.h's usageKeyFor); any + // other key is refused, enforcing the read-only-bank invariant structurally (banks/ + // view/tail/assign stay unwritable from the instrument). Returns true iff written + // (the SetProjExtState return is checked). NOT RT-safe — publish sites are the + // off-audio-thread reload path only. Deliberately does NOT mark the project dirty: a + // usage change always rides a component-state change that already does. bool writeUsageExtState(const std::string& usageKey, const std::string& value); - // The canonical "{XXXXXXXX-...}" GUID string of the track hosting this FX instance - // (getReaperParent(1) -> GetTrackGUID -> guidToString — the same rendering as the - // extension's track_guid::guidString, so usage records and the extension's live-FX - // enumeration compare byte-equal). Empty when unconnected or no track context (the - // usage reader then falls back to any-instance liveness — fail-safe). Not RT-safe. + // The canonical GUID string of the track hosting this FX instance (same rendering as + // the extension's track_guid::guidString, so usage records compare byte-equal + // against its live-FX enumeration). Empty when unconnected or no track context (the + // usage reader then falls back to any-instance liveness). Not RT-safe. std::string currentTrackGuid(); private: @@ -84,18 +67,14 @@ private: using EnumProjExtStateFn = bool (*)(void* proj, const char* extname, int idx, char* keyOut, int keyOut_sz, char* valOut, int valOut_sz); - // EnumProjects(-1, projfnOut, sz) -> active project + its .rpp path (SDK line - // ~1264). The instrument uses idx=-1 (current tab) so it follows the active project, - // and reads the .rpp path from the out-buffer exactly as the persist shell - // (ext_state_io.cpp) does. + // EnumProjects(-1, projfnOut, sz) -> active project + its .rpp path. idx=-1 (current + // tab) follows the active project, same convention as the persist shell. using EnumProjectsFn = void* (*)(int idx, char* projfnOut, int projfnOut_sz); - // SetProjExtState(proj, extname, key, value) -> int (SDK line ~6290). Used ONLY by - // writeUsageExtState (prefix-guarded) — see the read-only-bank note there. + // Used ONLY by writeUsageExtState (prefix-guarded) — see the read-only-bank note there. using SetProjExtStateFn = int (*)(void* proj, const char* extname, const char* key, const char* value); - // GetTrackGUID(MediaTrack*) -> GUID* (SDK ~3562) + guidToString(const GUID*, char* - // destNeed64) (SDK ~3848). Both held as opaque-pointer signatures so the header - // stays SDK-type-free; the GUID* is passed straight through, never dereferenced here. + // Opaque-pointer signatures so the header stays SDK-type-free; the GUID* is passed + // straight through, never dereferenced here. using GetTrackGuidFn = void* (*)(void* tr); using GuidToStringFn = void (*)(const void* g, char* destNeed64); diff --git a/src/shell/instrument/reasampler_editor.h b/src/shell/instrument/reasampler_editor.h index 3de98fd..73b2416 100644 --- a/src/shell/instrument/reasampler_editor.h +++ b/src/shell/instrument/reasampler_editor.h @@ -1,25 +1,8 @@ -// reasampler_editor.h — the VST3 IPlugView LICE editor for the ReaSampler 9000 -// capture-first UI (Phase S10). THIN shell: hosts a LICE-drawn child window inside the -// host's IPlugView seat and routes host paint/mouse into the pure geometry modules -// (capture_browser, keyboard_strip) + the pure mapping (sample_map). Windows-only (D5). -// -// The default face is the CAPTURE BROWSER: a bank-filter tab strip over a grid of -// scannable capture cards (peak thumbnail + name + root/key badge). A fresh instance with -// no pick shows a "pick a capture" EMPTY STATE and plays silence (the S10 policy reversal -// of the S4 first-sample auto-play). Picking a card loads that one capture and reveals a -// guided SINGLE-CAPTURE SETUP surface (a keyboard strip with the capture's root marker + -// a level readout). Multi-zone keymap editing is a demoted, opt-in ZONES panel (S10-Z), -// reached by a toggle and driven by the same keyboard_strip drag machine. -// -// All layout/hit-test/drag math lives in the pure modules; this shell only draws + routes -// (a LICE_SysBitmap blitted in WM_PAINT, a WM_LBUTTONDOWN/WM_MOUSEMOVE/WM_LBUTTONUP -// drag-state machine hit-testing via the pure resolvers). Peak thumbnails are computed -// shell-side from the decoded WAV (bank_model's Sample carries no envelope) and cached — -// the mirror of bank_panel::thumbnailFor. Every edit commits OFF the audio thread via the -// processor's reloadInstrument (RT path untouched). -// -// Subclasses CPluginView for the IPlugView boilerplate; overrides the attach/remove hooks -// to create/destroy the child window and onSize to resize it. +// reasampler_editor.h — VST3 IPlugView LICE editor for the ReaSampler 9000 UI. Thin shell: +// hosts a LICE child window, routing host paint/mouse into the pure geometry modules +// (capture_browser, keyboard_strip, sample_map) — default face is the capture browser, then +// single-capture setup, with an opt-in zones panel. All layout/hit-test/drag math lives in +// the pure modules; every edit commits off the audio thread via reloadInstrument. #pragma once @@ -30,13 +13,13 @@ #include "public.sdk/source/common/pluginview.h" -#include "core/instrument/ui/editor_geometry.h" // Rect (the shell's sub-rect type, shared with the pure modules) -#include "core/instrument/ui/envelope_edit.h" // EnvClampBounds / NodeHit (S-VIEW-3 envelope node hit-test/edit) -#include "core/instrument/ui/envelope_overlay.h" // AmpEnvelope / EnvNode (S-VIEW-3 envelope overlay draw seam) -#include "core/instrument/ui/knob_deck.h" // DeckGroupDesc / DeckLayout (r11 knob deck — Sample FB1, Zone FB2) +#include "core/instrument/ui/editor_geometry.h" // Rect (shared sub-rect type) +#include "core/instrument/ui/envelope_edit.h" // EnvClampBounds / NodeHit (envelope node hit-test/edit) +#include "core/instrument/ui/envelope_overlay.h" // AmpEnvelope / EnvNode (envelope overlay draw seam) +#include "core/instrument/ui/knob_deck.h" // DeckGroupDesc / DeckLayout (Sample + Zone knob deck) #include "core/audio/peaks.h" // Envelope (the cached peak thumbnail) #include "core/instrument/map/sample_map.h" // SampleChoice, BankChoice, PerformanceMap (the shell's snapshot) -#include "core/instrument/engine/velocity_curve.h" // VelocityCurve (S-VIEW-10 transfer-curve editor state) +#include "core/instrument/engine/velocity_curve.h" // VelocityCurve (transfer-curve editor state) #ifdef _WIN32 #include @@ -46,10 +29,6 @@ class LICE_IBitmap; // fwd: the paint helpers take one; lice.h is included only namespace reasampler::vst { -// Cross-subsystem deps by their real namespace homes (Q-W2v: the core/namespaces.h shim -// is retired from the editor family; engine symbols — ChannelMode, VoiceMode, MonoTrigger, -// the voice-count constants, VelocityCurve via the engine re-export — stay in flat -// `reasampler` and resolve via the enclosing namespace). using audio::AudioSample; using audio::Envelope; using instrument::map::BankChoice; @@ -69,9 +48,9 @@ class ReaSamplerProcessor; class ReaSamplerEditor : public Steinberg::CPluginView { public: - // `processor` owns this editor's lifetime domain and outlives it; the editor reads the - // live bank through it and drives selection/zone edits + reload on user input. May be - // null (defensive — a real host always supplies one). + // `processor` outlives this editor; the editor reads the live bank through it and drives + // selection/zone edits + reload on user input. May be null (defensive; a real host always + // supplies one). explicit ReaSamplerEditor(ReaSamplerProcessor* processor); ~ReaSamplerEditor() override; @@ -86,67 +65,51 @@ protected: Steinberg::tresult PLUGIN_API onSize(Steinberg::ViewRect* newSize) override; private: - // Which face the editor shows (S-VIEW-1, three-view model). Sample is the HOME/default - // face (the loaded capture). Browse is a full-window MODAL picker overlaid on Sample - // (select + confirm/cancel changes the loaded capture, then dismisses). Zone is the - // dedicated multi-zone keymap surface, button-summoned. All three draw over the same - // snapshotted bank; Browse + Zone return to Sample when dismissed. + // Sample is the home/default face. Browse is a full-window modal picker overlaid on + // Sample. Zone is the dedicated multi-zone keymap surface, button-summoned. enum class View { kSample, kBrowse, kZone }; - // What a mouse drag is currently editing (the drag-state machine). kNone = no drag in - // flight. The zone-edit grabs mirror keyboard_strip::ZoneGrab; kRootMarker is the - // single-capture root drag on the setup strip; kWaveMarker is a draggable start/loop - // marker on the S11 waveform surface (which marker is in waveMarker_); kEnvNode is a - // draggable envelope breakpoint on the Sample-view hero overlay (S-VIEW-3, which node in - // envNode_); kCurveNode is a draggable velocity-curve control point in the S-VIEW-10 - // transfer-curve editor (which point in curvePointIndex_); kDeckKnob is a GRAB-ANCHORED - // vertical radial-knob drag on an r11 knob deck — the Sample face's deck/cluster (FB1) - // or the Zone panel's per-zone deck (FB2) — (which control in dragParamId_; the value at - // grab in dragKnobStartValue_ — no jump on grab, FA4). + // What a mouse drag is currently editing. kWaveMarker/kEnvNode/kCurveNode track their + // grabbed item in waveMarker_/envNode_/curvePointIndex_; kDeckKnob is a grab-anchored + // knob drag (control in dragParamId_, grab value in dragKnobStartValue_). enum class DragKind { kNone, kRootMarker, kZoneLow, kZoneHigh, kZoneBody, kWaveMarker, kScrollThumb, kEnvNode, kCurveNode, kDeckKnob }; - // The parameter controls on the setup surface (S12 AHDSR + the S15/S16 control surfaces). - // The int value is the opaque control id the pure knob_deck hit-test returns; the shell - // maps it to the picked zone's play params (or a processor-side per-instance setter). + // Controls on the setup surface. The int value is the opaque control id the pure + // knob_deck hit-test returns; the shell maps it to the zone's play params or a + // processor-side per-instance setter. enum class ParamControl { - kPlayMode = 0, // Gate | Trigger toggle (S15) - kPitchEngine, // Varispeed | Preserve toggle (S16) + kPlayMode = 0, // Gate | Trigger toggle + kPitchEngine, // Varispeed | Preserve toggle kAttack, // AHDSR attack (Gate) / — - kHold, // AHDSR hold (Gate, S15) + kHold, // AHDSR hold (Gate) kDecay, // AHDSR decay (Gate) kSustain, // AHDSR sustain (Gate) kRelease, // AHDSR release (Gate) - kTrigLength, // Trigger %-length (Trigger, S15) - kTrigFadeIn, // Trigger fade-in (Trigger, S15) - kTrigFadeOut, // Trigger fade-out (Trigger, S15) - kPitchEnvEnable, // AD pitch envelope on|off (S16) - kPitchEnvAttack, // AD pitch attack (S16) - kPitchEnvDecay, // AD pitch decay (S16) - kPitchEnvDepth, // AD pitch depth in +/- semitones (S16) - kKeyTrack, // S-VIEW-6 key-tracking 0..200% (lives on PerformanceZone, not ZonePlaySeconds) - // r11 deck-only controls (FB1): processor-side per-instance params, NOT zone params — - // routed to the processor setters, never through applyZoneControl / the map. - kVoiceCount, // Phase S polyphony bound (1..32) — a stepped knob in the VOICE group + kTrigLength, // Trigger %-length + kTrigFadeIn, // Trigger fade-in + kTrigFadeOut, // Trigger fade-out + kPitchEnvEnable, // AD pitch envelope on|off + kPitchEnvAttack, // AD pitch attack + kPitchEnvDecay, // AD pitch decay + kPitchEnvDepth, // AD pitch depth in +/- semitones + kKeyTrack, // key-tracking 0..200% (lives on PerformanceZone, not ZonePlaySeconds) + // Deck-only controls: processor-side per-instance params, NOT zone params — routed to + // the processor setters, never through applyZoneControl / the map. + kVoiceCount, // polyphony bound (1..32) — a stepped knob in the VOICE group kVoiceMode, // Poly | Mono caption toggle (VOICE group) kMonoTrigger, // Retrig | Legato row toggle (VOICE group; live only in Mono) - kMasterGain, // FB1 post-mixer master gain knob (-inf..+24 dB taper, MASTER group) + kMasterGain, // post-mixer master gain knob (-inf..+24 dB taper, MASTER group) kCount }; - // The waveform markers on the single-capture setup surface (S11). Order is the draw + hit - // order (start first). Named generically per the spec so S15 can repurpose the surface with - // a different marker set; here it is start-point + the sustain loop's two ends. + // The waveform markers on the single-capture setup surface: start-point + the sustain + // loop's two ends, in draw + hit order. enum class WaveMarker { kStart = 0, kLoopStart = 1, kLoopEnd = 2, kCount = 3 }; - // --- Hover model (Phase L, L3) ------------------------------------------------ - // - // The interactive element under the pointer, resolved live in WM_MOUSEMOVE so the kit - // draws its hover state on that element only ("hover on every interactive element" + - // "sub-frame feedback = the perception of speed", §3.3/§3.5). Cleared to kNone on - // WM_MOUSELEAVE (tracked via TrackMouseEvent). `index` disambiguates within a kind - // (tab ordinal, visible-card index, control-row id); -1 when not applicable. Mirror of - // bank_panel's L2 hover model. + // The interactive element under the pointer, resolved live in WM_MOUSEMOVE. `index` + // disambiguates within a kind (tab ordinal, visible-card index, control-row id); -1 when + // not applicable. enum class HoverKind { kNone, kNavBrowse, // the Sample-view "Browse" title-band button (opens the Browse modal) @@ -163,10 +126,10 @@ private: kAddZone, // the "+ Add Zone" button kDeleteZone, // the "Delete" zone button kControl, // a knob-deck element (index = control id) - kCurveNode, // a velocity-curve control point (index = point index, S-VIEW-10) - kVelKnob, // the cluster preview-velocity radial knob (r11) - kCurveButton, // the cluster mini curve-preview button (r11 — opens the popup) - kPopupClose, // the curve popup's Close (x) button (r11) + kCurveNode, // a velocity-curve control point (index = point index) + kVelKnob, // the cluster preview-velocity radial knob + kCurveButton, // the cluster mini curve-preview button (opens the popup) + kPopupClose, // the curve popup's Close (x) button }; struct HoverTarget { HoverKind kind = HoverKind::kNone; @@ -177,96 +140,72 @@ private: #ifdef _WIN32 void paint(HDC hdc); - void paintSample(LICE_IBitmap* bmp, int w, int h); // S-VIEW-2/r11 home face - void paintBrowse(LICE_IBitmap* bmp, int w, int h); // S-VIEW-5 modal picker overlay - void paintZone(LICE_IBitmap* bmp, int w, int h); // S-VIEW-8 zone surface + void paintSample(LICE_IBitmap* bmp, int w, int h); // home face + void paintBrowse(LICE_IBitmap* bmp, int w, int h); // modal picker overlay + void paintZone(LICE_IBitmap* bmp, int w, int h); // zone surface void paintEmptyState(LICE_IBitmap* bmp, const Rect& area); - // --- r11 knob-deck rendering (FB1 Sample face; FB2 Zone panel) ------------------- - // The knob deck: the fenced task groups drawn through the L1 kit — group fence + caption + - // compact caption toggles + radial knobs (param_slider's FA4 primitive) with label<->value - // swap on hover/drag. `descs` picks the group set: the full Sample deck (deckGroupDescs) - // or the Zone panel's per-zone groups (zoneDeckGroupDescs). Lays out from deckArea's - // top-left; the caller anchors (Sample bottom-anchors, Zone top-anchors). + // The knob deck: group fence + caption + compact caption toggles + radial knobs with + // label<->value swap on hover/drag. `descs` picks the group set (Sample's deckGroupDescs + // or the Zone panel's zoneDeckGroupDescs); caller anchors (Sample bottom, Zone top). void paintKnobDeck(LICE_IBitmap* bmp, const Rect& deckArea, const PerformanceZone& zone, const std::vector& descs); - // The mini curve-preview button (shared by the Sample cluster + the Zone panel, FB2): a - // hairline bg/cell square tracing the zone's live curve; Active border while the popup is up. + // The mini curve-preview button shared by the Sample cluster + the Zone panel. void paintCurveButton(LICE_IBitmap* bmp, const Rect& r, const PerformanceZone& zone); - // The centered curve-popup sheet (wash + title + close + full-size curve editor). Edits - // popupZone() — the Sample face's one-zone site or the Zone surface's selected zone (FB2). + // The centered curve-popup sheet. Edits popupZone() — the Sample face's one-zone site + // or the Zone surface's selected zone. void paintCurvePopup(LICE_IBitmap* bmp, int w, int h); - // Trace the S-VIEW-3 amp-envelope overlay + its draggable node handles over `waveArea` for - // `zone`'s play params, at the sample's wall-clock duration. Shared by the Sample hero band. + // Traces the amp-envelope overlay + its draggable node handles over `waveArea`. void paintEnvelopeOverlay(LICE_IBitmap* bmp, const Rect& waveArea, const PerformanceZone& zone, std::int64_t frames); - // S-VIEW-10: the velocity->amp transfer-curve editor — a bordered box (X = velocity 0-127, - // Y = amp 0-1), the monotone spline traced by eval, one draggable node handle per control - // point. Since FB2 its ONLY host is the r11 popup sheet (both surfaces summon it via the - // mini preview button); all mapping / hit-test / clamp math lives in the pure - // velocity_curve module. `r` empty -> draws nothing. + // The velocity->amp transfer-curve editor (X = velocity 0-127, Y = amp 0-1); its only + // host is the popup sheet. `r` empty -> draws nothing. void paintVelocityCurve(LICE_IBitmap* bmp, const Rect& r, const PerformanceZone& zone); - // Route a mouse-down inside curve-editor box `r` editing map_.zones[zoneIndex]: a node grab - // starts a kCurveNode drag; Alt-click on an interior node deletes it (committed at once); - // an empty-space click ADDS a point at the cursor and grabs it for an immediate drag. - // `zoneIndex` must be a valid index into map_.zones (callers materialize first). + // Mouse-down inside curve-editor box `r` editing map_.zones[zoneIndex]: a node grab + // starts a kCurveNode drag; Alt-click on an interior node deletes it at once; an + // empty-space click adds a point and grabs it. `zoneIndex` must be valid (callers + // materialize first). void handleCurveMouseDown(const Rect& r, int zoneIndex, int x, int y); - // Route a left-click while the curve popup is open (the popup is MODAL over the Sample - // face AND the Zone surface, FB2): Close / outside-wash dismiss, in-box clicks into the - // shared curve machinery against popupZoneIndex(), everything else on the sheet swallowed. - // Returns true when the popup consumed the click (i.e. whenever it is open). + // Left-click while the curve popup is open (modal over both faces): Close / + // outside-wash dismiss, in-box clicks route to the curve machinery, else swallowed. + // Returns true whenever the popup is open (it consumed the click). bool handlePopupMouseDown(int w, int h, int x, int y); void onMouseDown(int x, int y); - // The Browse-modal and Zone-surface halves of the mouse-down dispatch (Q-W2v: the - // input TUs split along the face axis — onMouseDown keeps the Sample-face branch and - // delegates these two; bodies in editor_input_browse_zone.cpp). Behavior-identical - // to the former inline branches. + // The Browse-modal and Zone-surface halves of the mouse-down dispatch (bodies in + // editor_input_browse_zone.cpp). void mouseDownBrowse(int w, int h, int x, int y); void mouseDownZone(int w, int h, int x, int y); void onMouseMove(int x, int y); void onMouseUp(int x, int y); - // r11: right-click — the curve popup's PRIMARY node-delete affordance (issue 3c). Only - // acts while the popup is open (over the Sample face OR the Zone surface, FB2); a - // right-click on a popup curve node deletes it through the same commit path as Alt-click - // (deletePoint's endpoint guard makes endpoint right-clicks a safe no-op). Everything - // else ignores right-clicks. + // Right-click is the curve popup's primary node-delete affordance; only acts while the + // popup is open (deletePoint's endpoint guard makes an endpoint right-click a no-op). void onMouseRDown(int x, int y); - // Apply a knob/toggle interaction to map_.zones[zoneIndex] for control `id`: routes ordinary - // controls through applyControl against the zone's play struct, and kKeyTrack against the - // zone's keyTrack scalar (0..200% over the knob's 0..1). Used by both the click + drag paths. + // Applies a knob/toggle interaction to map_.zones[zoneIndex] for control `id`: ordinary + // controls route through applyControl; kKeyTrack writes the zone's keyTrack scalar + // (0..200% over the knob's 0..1). void applyZoneControl(int zoneIndex, int id, double value, int segment); - // Resolve the interactive element under (x, y) into hover_ (Phase L, L3). Called from - // WM_MOUSEMOVE (also while a drag is in flight — the resolved element just isn't used - // for a hover repaint mid-drag). Repaints only when the hovered element changed, so an - // idle mouse-move is free. Windows-only (the hit-tests use the shell's Win32 client rect). + // Resolves the interactive element under (x, y) into hover_, called from WM_MOUSEMOVE. + // Repaints only on change, so an idle move is free. Windows-only. void resolveHover(int x, int y); - // True iff element (kind, index) is the live hover_ target — the shell maps this to the - // kit's Hover interaction state when the element has no more-specific state (Active, etc.). bool isHovered(HoverKind kind, int index) const { return hover_.kind == kind && hover_.index == index; } - void onMouseWheel(int delta); // S12 browser scroll (wheel) - void onSearchChar(unsigned int ch); // S12 type-to-filter search keystroke + void onMouseWheel(int delta); // browser scroll (wheel) + void onSearchChar(unsigned int ch); // type-to-filter search keystroke - // S13 (relay degraded): an OS file drop landed on the editor window. We do NOT ingest (the - // instrument is a read-only bank consumer and the relay is unshipped) — we flash the "drop - // on the ReaSampler panel to add" affordance so the drop is never silently swallowed and the - // shipped ingest gesture stays discoverable. `droppedCount` is how many files were dropped - // (drawn into the banner). NEVER inserts a timeline item / never touches the bank. + // An OS file drop landed on the editor window. We do NOT ingest (read-only bank + // consumer) — flash a "drop on the ReaSampler panel to add" affordance instead of + // silently swallowing it. Never inserts a timeline item. void onFilesDropped(int droppedCount); - // The S9/S8 change-detection tick (WM_TIMER on the child window — the UI thread, NEVER the - // audio thread). Polls the processor's bank-sync (generation change -> hands-free reload; - // a new assignment request -> apply as this instance's selection) and, when anything - // changed, re-snapshots the editor's own view (refreshFromBank) + repaints so the browser / - // setup surface reflect the new bank. An open editor means THIS instance is the focused - // assignment target (the thundering-herd policy — see the handoff), so it passes true. - // Suppressed WHILE A DRAG IS IN FLIGHT so a mid-drag reload does not yank the edit surface. + // The change-detection tick (WM_TIMER, UI thread only): polls the processor's bank-sync + // and re-snapshots + repaints when anything changed. Suppressed mid-drag so a reload + // never yanks the edit surface. void onSyncTimer(); static LRESULT CALLBACK wndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam); @@ -279,37 +218,31 @@ private: // selection + performance map. Main/UI thread only. Called on attach and after any edit. void refreshFromBank(); - // Publish the edited zones/selection to the processor, then rebuild the instrument OFF - // the audio thread. UI thread only. One place so every edit commits identically. + // Publishes the edited zones/selection to the processor, then rebuilds the instrument + // off the audio thread. UI thread only. void commitAndReload(); - // Commit `id` as the loaded single-capture selection (the Browse Load confirm and the - // double-click accelerator both route here). Runs reconcileSingleCaptureZones first so - // the previous sample's materialized full-range zone cannot linger and shadow the new - // pick under first-match resolve (the zone-bleed fix, issue 3a), then publishes + reloads. + // Commits `id` as the loaded single-capture selection. Runs reconcileSingleCaptureZones + // first so the previous sample's materialized full-range zone cannot linger and shadow + // the new pick under first-match resolve, then publishes + reloads. void loadSelection(const std::string& id); - // Recompute the capture cards visible under the current bank filter (samples_ narrowed by - // activeFilterBankId_; "" = All) into visible_. Called on refresh + filter change. + // Recomputes the visible capture cards (samples_ narrowed by activeFilterBankId_ then + // search) into visible_. Called on refresh + filter change. void rebuildVisible(); - // The peak thumbnail for a bank sample id at `binCount` bins, computed once from the - // decoded WAV (mirror of bank_panel::thumbnailFor) and cached by (id, binCount). Returns - // an empty envelope when the WAV can't be resolved/decoded. UI thread only (file I/O). + // The peak thumbnail for a bank sample id at `binCount` bins, cached by (id, binCount). + // Empty envelope when the WAV can't be resolved/decoded. UI thread only (file I/O). const Envelope& thumbnailFor(const std::string& sampleId, int binCount); - // The decoded MONO PCM for a bank sample id, decoded once from the WAV and cached by id. - // Feeds the S11 waveform surface: the full-res envelope binned at view width AND the - // zero-crossing snap (both need the raw frames, not the binned thumbnail). Returns an empty - // vector when the WAV can't be resolved/decoded. UI thread only (file I/O). Reuses the same - // decode path as thumbnailFor (no new WAV reader), keyed by id (not width — snap is width- - // independent). Cleared with the thumbnail cache on refresh. + // The decoded mono PCM for a bank sample id, cached by id — feeds both the binned + // waveform envelope and the zero-crossing snap. Empty vector on decode failure. UI + // thread only (file I/O); cleared with the thumbnail cache on refresh. const std::vector& monoPcmFor(const std::string& sampleId); - // The effective loop + start markers for the picked single capture (S11): the per-zone - // OVERRIDE for the picked id when one exists in map_, else the bank's S2 loop intrinsic - // (loop) / frame 0 (start). Absent loop -> loopStart==loopEnd==0 (the "no loop" state). - // frames is the decoded length (for defaulting loopEnd when the bank left the loop empty). + // The effective loop + start markers for the picked capture: the per-zone override when + // one exists in map_, else the bank's loop intrinsic / frame 0. Absent loop -> + // loopStart==loopEnd==0. `frames` defaults loopEnd when the bank left the loop empty. struct SetupMarkers { std::int64_t start = 0; std::int64_t loopStart = 0; @@ -318,125 +251,93 @@ private: }; SetupMarkers pickedMarkers(std::int64_t frames) const; - // Commit an edited marker set for the picked capture as a per-zone loop/start override - // (upsert on the picked id — mirror of the root-marker path), then reload off-thread. + // Commits an edited marker set for the picked capture as a per-zone loop/start override + // (upsert on the picked id), then reloads off-thread. void commitPickedMarkers(const SetupMarkers& m); - // Write `m` as a loop/start override upsert into map_ for selectedId_ (find-or-append). - // Does NOT call commitAndReload — callers decide whether this is a live-drag update or a - // final commit. selectedId_ must be non-empty before calling. Returns the zone index - // (0-based) that was updated or appended, so callers can set selectedZone_. + // Writes `m` as a loop/start override upsert into map_ for selectedId_ (find-or-append). + // Does NOT call commitAndReload — callers decide live-drag vs final commit. selectedId_ + // must be non-empty. Returns the updated/appended zone index. int upsertPickedOverride(const SetupMarkers& m); - // --- S12/S15/S16 parameter value domains (both deck surfaces) ------------------ - // - // The deck knobs edit a zone's ZonePlaySeconds (S15 play mode + AHDSR; S16 pitch engine + - // AD pitch envelope). Wall-clock times are SECONDS (rate-free); the keymap build resolves - // them to frames at the live rate. Instrument-owned (D-B), never a bank fact. + // Deck knobs edit a zone's ZonePlaySeconds (play mode + AHDSR; pitch engine + AD pitch + // envelope) — wall-clock seconds, rate-free; the keymap build resolves to frames. - // The normalized [0,1] display value for control `id` given `play` (the shell's domain - // mapping: seconds->0..1 over a fixed seconds ceiling, sustain 0..1 as-is, %-length/fade - // frames->0..1, semitone depth centered at 0.5). + // The normalized [0,1] display value for control `id` given `play` (seconds -> 0..1 over + // a fixed ceiling, sustain 0..1 as-is, %-length/fade frames -> 0..1, semitone depth + // centered at 0.5). double controlValue(int id, const ZonePlaySeconds& play) const; - // Apply a committed control interaction to `play`: a knob's normalized `value` (mapped back - // into the control's stored domain) or a toggle's `segment` (0/1). Mutates `play` in place. + // Applies a committed control interaction to `play`: a knob's normalized `value` or a + // toggle's `segment` (0/1). Mutates `play` in place. void applyControl(int id, ZonePlaySeconds& play, double value, int segment) const; - // The Trigger fade-in/out knob full-scale, in SOURCE frames: kFadeMaxSeconds (2 s - // wall-clock) resolved against the live rate at use (Q-W0 T3-03 — never a baked-in - // rate). 44.1 kHz fallback before setupProcessing has run. Storage stays frames. + // The Trigger fade-in/out knob full-scale, in source frames: kFadeMaxSeconds resolved + // against the live rate — never a baked-in rate. 44.1 kHz fallback pre-setupProcessing. double fadeMaxFrames() const; - // --- S-VIEW-3 envelope overlay seam (frames <-> fraction converter) ---------- - // - // envelope_overlay's AmpEnvelope is a DERIVED VIEW, not a TriggerParams copy: it stores the - // Trigger fades as FRACTIONS of the played span, while the zone stores them as SOURCE FRAMES. - // These two members own the non-trivial conversion on BOTH paths (documented in - // envelope_overlay.h's TRIGGER SEAM note). `frames` is the sample's total source frame count; - // `rate` is the live sample rate (the wall-clock AHDSR seconds are rate-free and copy 1-to-1, - // but the Trigger played-span math needs the frame count). + // envelope_overlay's AmpEnvelope stores Trigger fades as fractions of the played span, + // while the zone stores source frames — pack/unpack own that conversion (see + // envelope_overlay.h's trigger-seam note). `frames` is total source frames; AHDSR + // seconds are rate-free and copy 1-to-1. - // PACK (draw): zone play params -> AmpEnvelope. Copies AHDSR seconds directly; derives the - // Trigger fade fractions from the source-frame fades over the played span. - // `startFrame` is the zone's effective start point (zone.startPoint.value_or(0)). + // PACK (draw): zone play params -> AmpEnvelope. `startFrame` is the zone's effective + // start point (zone.startPoint.value_or(0)). AmpEnvelope packEnvelope(const ZonePlaySeconds& play, std::int64_t frames, std::int64_t startFrame) const; - // UNPACK (commit): an edited AmpEnvelope -> the zone's play params. Copies AHDSR seconds - // directly; converts the Trigger fade fractions back to source frames over the played span. - // `startFrame` is the zone's effective start point (zone.startPoint.value_or(0)). - // Mutates `play` in place; only the mode-relevant fields are written. + // UNPACK (commit): an edited AmpEnvelope -> the zone's play params, in place. void unpackEnvelope(const AmpEnvelope& env, std::int64_t frames, std::int64_t startFrame, ZonePlaySeconds& play) const; - // The clamp bounds envelope_edit uses, matching the control-panel sliders' own domains (so a - // node drag can never produce a param a slider couldn't — the S-VIEW-F2 invariant). + // Clamp bounds envelope_edit uses, matching the sliders' own domains so a node drag can + // never produce a param a slider couldn't. EnvClampBounds envClampBounds() const; - // --- Sample-view resolution helpers (the ONE storage site, S15-F2) ----------- - // - // The single-capture Sample face reads/writes the same one-zone map site as the Zone surface. - // These resolve the effective values for the picked id: effectiveSampleZone returns the picked - // id's one-zone override (found in map_) or a product-default PerformanceZone bound to the - // picked id (not yet materialized — a control edit materializes it, mirroring the Zone path). + // The Sample face and the Zone surface read/write the same one-zone map site. + // effectiveSampleZone returns the picked id's override if present in map_, else a + // product-default zone (not yet materialized — a control edit does that). PerformanceZone effectiveSampleZone() const; - // The effective root: the picked id's rootOverride, else its bank intrinsic, else middle C. + // The effective root: rootOverride, else the bank intrinsic, else middle C. int effectiveRoot() const; - // The live sample rate from the bridge (for the envelope overlay's seconds<->frames time base), - // or 0 when unavailable (the caller guards). Matches the voice engine's resolution rate. + // The live sample rate from the bridge, or 0 when unavailable (caller guards). double liveSampleRate() const; - // The persisted preview velocity as a 0..1 slider value (MIDI 1..127 mapped onto [0,1]). + // Persisted preview velocity as a 0..1 slider value (MIDI 1..127 -> [0,1]). double previewVelocity01() const; - // Find-or-materialize the one-zone override for the picked id and return a mutable index into - // map_.zones (appending a product-default zone if none exists). selectedId_ must be non-empty. - // The mirror of upsertPickedOverride for a control edit — used when a Sample-face control edit - // needs a concrete zone to write. Returns -1 if selectedId_ is empty. + // Find-or-materializes the one-zone override for the picked id, appending a + // product-default zone if none exists. Mirror of upsertPickedOverride for a control + // edit. Returns -1 if selectedId_ is empty. int ensureSampleZone(); - // --- Curve-popup target resolution (r11 FB1 + FB2) ----------------------------- - // - // The popup edits ONE zone per open: the Zone surface's SELECTED zone (FB2) or the Sample - // face's picked one-zone site. popupZone is the read-only resolve (paint/hover/right-click - // hit-test); popupZoneIndex is the edit target — it materializes the Sample-face zone via - // ensureSampleZone but NEVER materializes on the Zone surface (the button only shows for - // an explicit selection). Returns -1 when there is no valid target (callers guard). + // The popup edits ONE zone per open: the Zone surface's selected zone or the Sample + // face's picked site. popupZone is the read-only resolve; popupZoneIndex is the edit + // target — materializes on the Sample face via ensureSampleZone, never on the Zone + // surface (button only shows for an explicit selection). -1 = no valid target. PerformanceZone popupZone() const; int popupZoneIndex(); - // --- r11 knob-deck plumbing (FB1 Sample face; FB2 Zone panel) ------------------- - // - // The deck is the r11 replacement for the slider control strips on BOTH surfaces: the pure - // knob_deck module lays out the fenced groups, param_slider's FA4 primitive owns the - // value<->needle map, and these members own the control-id <-> value binding. - - // The PER-ZONE deck groups (FB2 — the set both surfaces share): AMP ENVELOPE (Gate: - // A/H/D/S/R; Trigger: Fade In / Length % / Fade Out + two RESERVED blanks so a mode flip - // never reflows the neighbours) / PITCH (Key Track) / PITCH ENV (P.Attack/P.Decay/P.Depth). - // The Zone panel renders exactly these — per-instance state stays off it. + // The per-zone deck groups both surfaces share: AMP ENVELOPE (Gate A/H/D/S/R; Trigger + // Fade In/Length %/Fade Out + two reserved blanks so a mode flip never reflows + // neighbours) / PITCH (Key Track) / PITCH ENV (P.Attack/P.Decay/P.Depth). std::vector zoneDeckGroupDescs(const ZonePlaySeconds& play) const; - // The full Sample-face deck: the shared per-zone groups + the per-instance VOICE (Voices - // knob + Poly|Mono caption toggle + Retrig|Legato row toggle) and MASTER (the FB1 - // post-mixer Gain knob) groups. + // The full Sample-face deck: the shared groups + the per-instance VOICE (Voices knob + + // Poly|Mono + Retrig|Legato) and MASTER (Gain knob) groups. std::vector deckGroupDescs(const ZonePlaySeconds& play) const; // The normalized [0,1] value a deck knob shows for `zone` — zone params route through - // controlValue/keyTrack; the processor-side ids (voice count, master gain, and the - // cluster's preview velocity via the -2 sentinel) read the processor's live value, so - // the knob and its storage are two views on one model (re-read each paint). + // controlValue/keyTrack; processor-side ids (voice count, master gain, preview velocity + // via the -2 sentinel) read the processor's live value. double deckControlNorm(int id, const PerformanceZone& zone) const; - // Apply a deck-knob value: zone params write map_.zones[zoneIndex] (live-drag semantics, - // commit on release); processor params (voice count / master gain / preview velocity) - // write through the processor setters immediately (transient — no map edit, no reload). - // zoneIndex is ignored for processor-side ids. + // Applies a deck-knob value: zone params write map_.zones[zoneIndex] (commit on + // release); processor params write through the processor setters immediately + // (transient — no map edit, no reload). zoneIndex ignored for processor-side ids. void applyDeckKnob(int zoneIndex, int id, double norm); - // The knob's live value label (shown in place of the name label during hover/drag): - // seconds ("0.123s"), percents ("85%"), source frames ("8820f"), signed semitones - // ("+3.5st"), a voice count ("16"), or the master-gain dB ("-inf"/"+2.4dB"). + // The knob's live value label shown during hover/drag: seconds, percents, source + // frames, signed semitones, a voice count, or the master-gain dB. std::string deckValueLabel(int id, const PerformanceZone& zone) const; ReaSamplerProcessor* processor_ = nullptr; @@ -447,64 +348,51 @@ private: std::vector visible_; // samples_ narrowed by the active bank filter std::string selectedId_; // the single-capture pick ("" = empty state) PerformanceMap map_; // the opt-in zones (empty = no zones) - ChannelMode channelMode_ = ChannelMode::Mono; // S7 mono/stereo toggle snapshot + ChannelMode channelMode_ = ChannelMode::Mono; // mono/stereo toggle snapshot - // --- Phase S voice-deck snapshot (PROVISIONAL controls — the Wave B recompose owns the - // final deck). Mirrors of the processor's persisted voice-system params, refreshed with - // the rest of the live snapshot; every edit writes through the processor setters (which - // rebuild the engine off-thread via the drain-slot swap). + // Mirrors of the processor's persisted voice-system params, refreshed with the rest of the + // live snapshot; every edit writes through the processor setters (which rebuild the engine + // off-thread via the drain-slot swap). int voiceCount_ = kDefaultVoiceCount; VoiceMode voiceMode_ = VoiceMode::Poly; MonoTrigger monoTrigger_ = MonoTrigger::Retrigger; - // --- Transient UI state (not persisted; component state carries selection + zones) --- - View view_ = View::kSample; // default face is the loaded-sample home (S-VIEW-1) + // Transient UI state (not persisted; component state carries selection + zones). + View view_ = View::kSample; // default face is the loaded-sample home std::string activeFilterBankId_; // "" = All; else a bank id from banks_ int selectedZone_ = -1; // highlighted zone in the Zone surface; -1 = none - // --- S-VIEW-5 Browse modal picker (a selection PENDING confirm) --------------- - // The Browse overlay is a select-then-confirm picker: a click marks a pending pick without - // loading it; Confirm (or double-click) commits it to selectedId_ + reloads and returns to - // Sample; Cancel discards it and returns to Sample unchanged. "" = nothing picked yet. + // The Browse overlay is a select-then-confirm picker: a click marks a pending pick; + // Confirm/double-click commits it + reloads; Cancel discards it. "" = nothing picked. std::string browsePendingId_; int lastBrowseClickCard_ = -1; // for double-click-to-load detection (visible_ index) - // --- S-VIEW-4 preview-trigger note (transient) ------------------------------- - // The MIDI note the preview button is currently sounding (a held Gate voice), or -1 when the - // button is up. Set on preview-button press (note-on posted to the processor), cleared on - // release (note-off posted). One note at a time — a fresh press releases the prior. + // The MIDI note the preview button is currently sounding (held Gate voice), or -1 when + // up. One note at a time — a fresh press releases the prior. int previewingNote_ = -1; - // --- S13 drop-to-load affordance (relay DEGRADED — transient, never persisted) ---- - // S13's cross-artifact ingest relay (editor drop -> extension ingest) is NOT shipped: the - // instrument's REAPER bridge is deliberately READ-ONLY (it never writes the bank / ext - // state), so an editor drop cannot relay a bank-ingest request without a new write seam + - // an extension-side poller (surfaced as a decision, not crossed here). The DEGRADE path per - // the spec: the editor ACCEPTS the drop (WM_DROPFILES) and, rather than silently swallowing - // it, flashes a clear affordance pointing at the shipped ingest gesture (drop onto the - // docked ReaSampler panel). When > 0, the affordance banner is shown; each sync tick decays - // it so it auto-dismisses. No file is ingested, no timeline item is ever inserted. + // The editor-drop -> extension-ingest relay is not shipped (the bridge is read-only): + // an OS drop just flashes a "drop on the panel instead" banner (dropHintTicks_ counts + // down via the sync tick). Never ingests, never inserts a timeline item. int dropHintTicks_ = 0; // remaining sync ticks to show the drop affordance - // --- S12 browser scroll + search (transient UI state, never persisted) -------- + // Browser scroll + search (transient UI state, never persisted). int scrollOffset_ = 0; // vertical px offset into the card grid (clamped) std::string searchQuery_; // type-to-filter narrow; "" = no search bool searchFocused_ = false; // whether the search box has keyboard focus - // --- S12 numeric note entry (LICE text-entry idiom, transient) ---------------- - // When >= 0, a low/high/root field is being typed; entryText_ accumulates the keystrokes - // and commits (parseNoteEntry) on Enter. -1 = no field editing. The field id is a - // ParamControl-independent small enum encoded inline (see the .cpp: 0=low,1=high,2=root). + // When >= 0, a low/high/root field is being typed (0=low,1=high,2=root); entryText_ + // accumulates keystrokes and commits via parseNoteEntry on Enter. -1 = no field editing. int entryField_ = -1; std::string entryText_; - // --- Hover state (Phase L, L3; transient, never persisted) -------------------- + // Hover state (transient, never persisted). HoverTarget hover_; // the interactive element under the pointer #ifdef _WIN32 bool mouseTracking_ = false; // TrackMouseEvent armed for WM_MOUSELEAVE this "over" cycle #endif - // --- Drag-state machine ------------------------------------------------------ + // Drag-state machine. DragKind drag_ = DragKind::kNone; int dragStartX_ = 0; // grab x (px), for the pixel-delta resolver int dragStartY_ = 0; // grab y (px), for the vertical scrollbar-thumb drag @@ -515,54 +403,46 @@ private: int dragStartRoot_ = 60; PerformanceMap dragStartMap_; // map_ snapshotted at grab; restored on capture-loss - // S11 waveform-marker drag: which marker + the marker set snapshotted at grab time (so the - // pixel-delta resolver shifts the grabbed frame from its grab-time value, and inter-marker - // clamps use the sibling markers). + // Waveform-marker drag: which marker + the marker set snapshotted at grab time, so the + // pixel-delta resolver shifts from the grab-time value and inter-marker clamps use the + // sibling markers. WaveMarker waveMarker_ = WaveMarker::kStart; SetupMarkers dragStartMarkers_; std::int64_t dragSampleFrames_ = 0; // decoded length of the sample under the drag std::int64_t dragStartFrame_ = 0; // zone startPoint at grab time (0 if absent); for env-node drag - // S12 scrollbar-thumb drag: the offset held at grab time (the pixel-delta resolver shifts - // from it). kDeckKnob drag: which control id + the zone it edits. + // Scrollbar-thumb drag: the offset at grab time. kDeckKnob drag: which control id + zone. int dragStartScrollOffset_ = 0; int dragParamId_ = -1; // control id under a kDeckKnob drag; -2 = preview-vel knob int dragParamZone_ = -1; // the zone index a kDeckKnob drag edits; -1 = processor-side - // S-VIEW-3 envelope-node drag: which node is grabbed + the AmpEnvelope snapshotted at grab - // (so the pixel delta is absolute, per envelope_edit's grabEnv contract). The overlay rect + - // sample frame count are re-derived at move time from the live Sample-view layout. + // Envelope-node drag: which node + the AmpEnvelope snapshotted at grab (absolute-delta + // contract, per envelope_edit's grabEnv). EnvNode envNode_ = EnvNode::Origin; AmpEnvelope dragStartEnv_{}; - // S-VIEW-10 velocity-curve node drag: which point is grabbed, the curve snapshotted at grab - // (resolvePointDrag's absolute-delta contract), the box rect the grab happened in (the Sample - // and Zone views place the editor differently — the drag resolves against the grab-time box), - // and which zone the edit lands on. Mirror of the envelope-node drag state. + // Velocity-curve node drag: which point, the curve snapshotted at grab + // (resolvePointDrag's absolute-delta contract), the grab-time box rect (Sample and Zone + // place the editor differently), and which zone the edit lands on. int curvePointIndex_ = -1; VelocityCurve dragStartCurve_ = VelocityCurve::flat(); Rect dragCurveRect_{}; int dragCurveZone_ = -1; - // r11 deck-knob drag (FB1): the control's normalized value AT GRAB — knobDragValue maps - // the vertical pixel delta from this anchor, so a grab never jumps the value (FA4). + // Deck-knob drag: the normalized value at grab — knobDragValue maps the vertical pixel + // delta from this anchor, so a grab never jumps the value. double dragKnobStartValue_ = 0.0; - // r11 curve popup (FB1 + FB2): open flag — editor-local, never persisted. The popup edits - // popupZone() — the picked capture's one-zone site on the Sample face, the SELECTED zone - // on the Zone surface — re-resolved each paint so a sync-tick refresh mid-open stays - // coherent (a refresh that drops the target closes it; see refreshFromBank). + // Curve popup open flag, never persisted. Edits popupZone(), re-resolved each paint so a + // sync-tick refresh mid-open stays coherent (a refresh that drops the target closes it). bool curvePopupOpen_ = false; - // --- Peak-thumbnail cache (mirror of bank_panel; id -> envelope at a bin width) ------ - // Keyed by "id|binCount" so a resize recomputes at the new width. Cleared on refresh so - // a bank edit (a re-captured or deleted sample) does not show a stale thumbnail. + // Peak-thumbnail cache (mirror of bank_panel), keyed by "id|binCount" so a resize + // recomputes at the new width. Cleared on refresh so a stale sample never shows. std::unordered_map thumbCache_; - // --- Decoded mono-PCM cache (S11; id -> full-res frames) ------------------------------ - // Keyed by id (width-independent, unlike thumbCache_). Feeds the waveform envelope binning - // + the zero-crossing snap. Cleared alongside thumbCache_ on refresh so a re-captured or - // deleted sample does not show/snap against stale PCM. + // Decoded mono-PCM cache, keyed by id (width-independent). Feeds the waveform envelope + // binning + zero-crossing snap. Cleared alongside thumbCache_ on refresh. std::unordered_map> pcmCache_; }; diff --git a/src/shell/instrument/reasampler_embed.cpp b/src/shell/instrument/reasampler_embed.cpp index 53e9d8d..7b7a1b6 100644 --- a/src/shell/instrument/reasampler_embed.cpp +++ b/src/shell/instrument/reasampler_embed.cpp @@ -1,29 +1,29 @@ // reasampler_embed.cpp — see reasampler_embed.h. The IReaperUIEmbedInterface shell. -// Windows-only (D5); guarded so a non-Windows build degrades to a stub that reports -// "not supported" and draws nothing. +// Windows-only; guarded so a non-Windows build degrades to a stub that reports "not +// supported" and draws nothing. #include "shell/instrument/reasampler_embed.h" #include #include -#include "core/version/app_version.h" // vstPluginName (channel-derived embed label, S18) -#include "core/instrument/map/bank_sync.h" // parseBankGeneration (S9 dirty-guard over the per-paint refresh) -#include "core/ui/component_geometry.h" // KitBox — the kit text/fill draw box (Phase L, L3) -#include "shell/panel/draw_kit.h" // the L1 draw kit: fillSurface/text (L3) +#include "core/version/app_version.h" // vstPluginName (channel-derived embed label) +#include "core/instrument/map/bank_sync.h" // parseBankGeneration (dirty-guard over the per-paint refresh) +#include "core/ui/component_geometry.h" // KitBox — the kit text/fill draw box +#include "shell/panel/draw_kit.h" // the shared draw kit: fillSurface/text #include "core/instrument/ui/editor_geometry.h" // Rect (shared with embed_strip) #include "core/instrument/ui/embed_strip.h" // the pure strip layout + hit-test #include "ext_keys.h" // kProjExtBanksKey / kProjExtBankGenKey #include "shell/instrument/reaper_bridge.h" #include "shell/instrument/reasampler_processor.h" -#include "core/ui/theme.h" // Role / InteractionState / spectralColor (L3) +#include "core/ui/theme.h" // Role / InteractionState / spectralColor -// wdltypes.h first: it defines INT_PTR portably (and pulls on Windows), which -// reaper_plugin_fx_embed.h's REAPER_FXEMBED_IBitmap::Extended needs as its return type. +// wdltypes.h first: it defines INT_PTR portably (needed by REAPER_FXEMBED_IBitmap::Extended's +// return type in the header below). #include "wdltypes.h" -// REAPER's embed message/bitmap contract (vendored). REAPER_FXEMBED_IBitmap is an alias of -// LICE_IBitmap, and the WM_* / DrawInfo / SizeHints definitions live here. +// REAPER's embed message/bitmap contract (vendored): REAPER_FXEMBED_IBitmap aliases +// LICE_IBitmap; WM_* / DrawInfo / SizeHints live here. #include "reaper_plugin_fx_embed.h" #ifdef _WIN32 @@ -34,17 +34,12 @@ using namespace Steinberg; -// DECLARE_CLASS_IID in the REAPER header only DECLARES IReaperUIEmbedInterface::iid; some -// TU must DEFINE it. This is the only place that answers queryInterface for it, so the -// definition lives with its sole use (mirrors reaper_bridge.cpp doing this for -// IReaperHostApplication). +// This is the only TU that answers queryInterface for IReaperUIEmbedInterface, so the +// DEFINE lives here (mirrors reaper_bridge.cpp's IReaperHostApplication). DEF_CLASS_IID(Steinberg::IReaperUIEmbedInterface) namespace reasampler::vst { -// Real-namespace-home using-directives (Q-W6: the namespaces.h shim is retired): -// the embed strip speaks the map vocabulary (listSamples / parseBankGeneration) and -// the pure UI layout (embed_strip / editor_geometry Rect) wholesale. using namespace reasampler::instrument::map; using namespace reasampler::instrument::ui; using reasampler::ui::spectralColor; @@ -52,15 +47,13 @@ using version::vstPluginName; namespace { #ifdef _WIN32 -// Kit adapter (Phase L, L3): the embed shell's Rect (editor_geometry) -> the kit's KitBox -// (component_geometry). Every embed surface now draws by palette ROLE via the L1 kit, retiring -// the local pre-L1 forest-green palette + raw GDI DrawTextA. +// Kit adapter: the embed shell's Rect -> the kit's KitBox. KitBox toKitBox(const Rect& r) { return KitBox{r.x, r.y, r.width, r.height}; } -// A short display name for a bank sample id, from the snapshotted list (the editor's helper, -// duplicated small rather than shared across the shell/pure boundary). +// A short display name for a bank sample id (small duplicate of the editor's helper +// rather than shared across the shell/pure boundary). std::string sampleLabel(const std::vector& samples, const std::string& id) { for (const SampleChoice& c : samples) { if (c.id == id) return c.displayName.empty() ? c.id : c.displayName; @@ -69,9 +62,8 @@ std::string sampleLabel(const std::vector& samples, const std::str } #endif -// Project the instrument's performance map into the strip's minimal zone shape (key ranges -// only). Pure projection — kept here (shell side) because it reads PerformanceMap, a shell -// type; embed_strip stays free of it. +// Projects the performance map into the strip's minimal zone shape (key ranges only). +// Kept shell-side because it reads PerformanceMap; embed_strip stays free of it. std::vector toEmbedZones(const PerformanceMap& map) { std::vector out; out.reserve(map.zones.size()); @@ -104,28 +96,24 @@ void ReaSamplerEmbed::refresh() { void ReaSamplerEmbed::maybeRefresh() { if (!processor_) { refresh(); return; } // clears state; cheap - // The performance map is a cheap in-process accessor (mutex + copy), and the editor may - // have edited zones with NO bank-content change — always re-snapshot it so a zone edit - // reflects immediately. + // The performance map is a cheap in-process accessor, and the editor may edit zones + // with no bank-content change — always re-snapshot it so an edit reflects immediately. map_ = processor_->performanceMap(); if (selectedZone_ >= static_cast(map_.zones.size())) selectedZone_ = -1; - // The EXPENSIVE part is the bank-blob bridge read (samples_). Gate it on the S9 bank- - // generation stamp (a small ext-state read): only re-read the bank when the generation - // changed since the last paint (a recapture / ingest / remove), or on the first paint - // (lastSeenBankGeneration_ == -1). A pre-S9 project reads generation 0; the first paint - // folds it and subsequent idle paints skip the bank read entirely. + // The expensive part is the bank-blob bridge read: gate it on the bank-generation + // stamp, re-reading only when it changed (or on the first paint). A project with no + // stamp reads generation 0; the first paint folds it and idle paints skip the read. std::int64_t currentGen = lastSeenBankGeneration_; if (auto rawGen = processor_->bridge().readReasamplerExtState(reasampler::kProjExtBankGenKey)) { currentGen = parseBankGeneration(*rawGen); } else if (lastSeenBankGeneration_ < 0) { - currentGen = 0; // unprimed + no stamp (pre-S9): treat as generation 0 for the first read + currentGen = 0; // unprimed + no stamp: treat as generation 0 for the first read } - // Intentional asymmetry: a TRANSIENT bridge failure (readReasamplerExtState returned - // nullopt after we were already primed) leaves currentGen == lastSeenBankGeneration_, - // so the bank-blob read is skipped and the editor keeps its last-known sample list. - // A stale-but-intact list is better than clearing samples_ on every transient hiccup. + // Intentional asymmetry: a transient bridge failure after priming leaves currentGen + // unchanged, skipping the read — a stale-but-intact list beats clearing samples_ on + // every hiccup. if (lastSeenBankGeneration_ < 0 || currentGen != lastSeenBankGeneration_) { auto banks = @@ -145,9 +133,8 @@ TPtrInt ReaSamplerEmbed::embed_message(int msg, TPtrInt parm2, TPtrInt parm3) { #endif case REAPER_FXEMBED_WM_CREATE: #ifdef _WIN32 - // Create the kit's cached AA fonts before the first paint (Phase L, L3). - // Idempotent + process-global (shared with the editor in this binary); NOT torn - // down per-view — the OS reclaims the tiny static HFONT set at module unload. + // Idempotent + process-global (shared with the editor); not torn down per-view + // — the OS reclaims the tiny static HFONT set at module unload. kitFontsInit(); #endif refresh(); // prime the first paint's snapshot @@ -157,8 +144,7 @@ TPtrInt ReaSamplerEmbed::embed_message(int msg, TPtrInt parm2, TPtrInt parm3) { case REAPER_FXEMBED_WM_GETMINMAXINFO: { auto* hints = reinterpret_cast(parm3); if (!hints) return 0; - // Minimum usable strip height: the keymap must not collapse below its floor - // (kEmbedKeymapMinHeight) plus the level band. + // The keymap must not collapse below its floor plus the level band. hints->min_width = 64; hints->max_width = 0; // 0 = unconstrained hints->min_height = kEmbedKeymapMinHeight + kEmbedLevelBandHeight; @@ -172,7 +158,7 @@ TPtrInt ReaSamplerEmbed::embed_message(int msg, TPtrInt parm2, TPtrInt parm3) { case REAPER_FXEMBED_WM_PAINT: return paint(parm2, parm3) ? 1 : 0; case REAPER_FXEMBED_WM_LBUTTONDOWN: - // Selection at most (S6): map the click to a zone; force a redraw if it changed. + // Selection at most: map the click to a zone; force a redraw if it changed. return onMouseDown(parm3) ? REAPER_FXEMBED_RETNOTIFY_INVALIDATE : 0; #endif default: @@ -190,35 +176,30 @@ bool ReaSamplerEmbed::paint(TPtrInt bitmap, TPtrInt drawInfo) { const int h = di->height; if (w <= 0 || h <= 0) return false; - // Re-read live state each paint (UI thread) so the strip reflects keymap edits + bank - // changes without its own timer — REAPER repaints the embed surface on its cadence. S9 - // dirty-guard: maybeRefresh does the EXPENSIVE bank-blob read only when the bank generation - // changed (the flagged S6 follow-up), always refreshing the cheap performance map. + // Re-read live state each paint (no own timer) — REAPER repaints the embed surface on + // its own cadence. maybeRefresh(); - // REAPER hands us its own bitmap sized to the embed area; draw directly into it (unlike - // the editor, which owns a LICE_SysBitmap and BitBlt's). Origin is the bitmap's (0,0). - // Base canvas through the kit (bg/base + micro-gradient), Phase L L3. + // REAPER hands us its own bitmap sized to the embed area; draw directly into it + // (unlike the editor, which owns a LICE_SysBitmap and BitBlt's). fillSurface(bmp, KitBox{0, 0, w, h}, Role::BgBase, InteractionState::Rest); const EmbedLayout layout = layoutEmbed(w, h); if (map_.zones.empty()) { - // No opt-in zones authored: a faint bg/cell band spanning the keymap area so the strip - // reads as "present, no zones" — the default single-capture face lives in the editor. + // No opt-in zones authored: a faint band so the strip reads as "present, no zones" + // — the default single-capture face lives in the editor. LICE_FillRect(bmp, layout.keymap.x, layout.keymap.y, layout.keymap.width, layout.keymap.height, toLice(roleColor(Role::BgCell)), 0.5f, 0); - const std::string label = version::vstPluginName() + // channel-derived (S18) + const std::string label = version::vstPluginName() + // channel-derived (samples_.empty() ? " (bank empty)" : " (no zones)"); const Rect labelR = Rect::ltrb(layout.keymap.x + 4, layout.keymap.y, layout.keymap.right(), layout.keymap.bottom()); text(bmp, toKitBox(labelR), label.c_str(), Font::Label, Role::TextPrimary, Align::Left); } else { - // Draw each zone as a segment across the keymap span, first-match order (so the painted - // order matches selection + playback). Each segment takes its PASTEL SPECTRAL hue from - // the center of its key span (spectralColor — §4), so the strip reads as the same - // spectrum as the editor's keyboard strip. The SELECTED zone lifts to accent-primary - // + a static glow ("which zone is live", never a pulse — §3.5). + // Each zone draws as a segment (first-match order, matching selection/playback), + // colored by its key span's spectral hue so it reads as the same spectrum as the + // editor's keyboard strip. The selected zone lifts to accent-primary + a static glow. for (int i = 0; i < static_cast(map_.zones.size()); ++i) { const PerformanceZone& z = map_.zones[i]; const Rect r = zoneSegmentRect(layout, z.lowNote, z.highNote); @@ -237,9 +218,8 @@ bool ReaSamplerEmbed::paint(TPtrInt bitmap, TPtrInt drawInfo) { } LICE_DrawRect(bmp, r.x, r.y, r.width - 1, r.height - 1, toLice(roleColor(Role::LineHairline)), 1.0f, 0); - // Label the segment with the sample name when it is wide enough to read. The - // selected (accent-fill) segment draws its label in bg/base for contrast (the - // tight text-on-pastel pair, §4); the rest in text/primary. + // Label when wide enough to read; the selected (accent-fill) segment labels in + // bg/base for contrast, the rest in text/primary. if (r.width >= 24) { const Rect lr = Rect::ltrb(r.x + 3, r.y, r.right() - 2, r.bottom()); text(bmp, toKitBox(lr), sampleLabel(samples_, z.sampleId).c_str(), @@ -248,8 +228,8 @@ bool ReaSamplerEmbed::paint(TPtrInt bitmap, TPtrInt drawInfo) { } } - // The level band: a recessed bg/cell channel with an accent-primary fill following the - // live activity level (a direct level follow — the one permitted "motion", §3.5). + // The level band: a recessed channel with an accent-primary fill tracking the live + // activity level (the one permitted "motion"). if (layout.levelBand.height > 0) { fillSurface(bmp, toKitBox(layout.levelBand), Role::BgCell, InteractionState::Pressed); const double level = processor_ ? processor_->embedActivityLevel() : 0.0; diff --git a/src/shell/instrument/reasampler_embed.h b/src/shell/instrument/reasampler_embed.h index bcc31c1..8e7342d 100644 --- a/src/shell/instrument/reasampler_embed.h +++ b/src/shell/instrument/reasampler_embed.h @@ -1,34 +1,9 @@ -// reasampler_embed.h — the S6 embedded TCP/MCP UI shell. Implements REAPER's -// IReaperUIEmbedInterface (vendor/reaper-sdk/sdk/reaper_plugin_fx_embed.h + -// reaper_vst3_interfaces.h) so the instrument draws a compact keymap/level strip INLINE in -// the track/mixer control panel — the same Cockos surface REAPER's own embedded FX use. -// -// VERIFIED CONTRACT (against reaper_plugin_fx_embed.h + reaper_vst3_interfaces.h): -// * VST3 exposes this by having the IEditController answer queryInterface for -// IReaperUIEmbedInterface (iid {0x049bf9e7,0xbc74ead0,0xc4101e86,0x7f725981}). Our -// SingleComponentEffect IS the edit controller, so the processor's queryInterface hands -// REAPER a reference to this object. -// * The single method is embed_message(int msg, TPtrInt parm2, TPtrInt parm3). msg is a -// REAPER_FXEMBED_WM_* value (aliased to Win32 WM_*): -// - WM_IS_SUPPORTED (0x0000): return 1 (supported+available), -1, or 0. -// - WM_CREATE (0x0001) / WM_DESTROY (0x0002): embed begin/end; return ignored. -// - WM_PAINT (0x000F): parm2 = REAPER_FXEMBED_IBitmap* (alias LICE_IBitmap) to draw -// into; parm3 = REAPER_FXEMBED_DrawInfo* (context TCP=1/MCP=2, width/height, mouse, -// flags). Return 1 if drawing occurred, 0 otherwise. -// - WM_GETMINMAXINFO (0x0024): parm3 = SizeHints*; return 1 if filled. -// - mouse WM_* (0x0200..0x020A): parm3 = DrawInfo*; return RETNOTIFY_INVALIDATE -// (0x1000000) to force a redraw. Capture is auto-managed by the host. -// * There is NO plugin-owned window/HWND here (unlike the IPlugView editor): REAPER hands -// a LICE bitmap per paint; we only draw into it and read mouse coords from DrawInfo. -// -// RT DISCIPLINE (S6 constraint): all embed messages arrive on REAPER's UI thread; nothing -// here runs in process(). It reads the same live state the editor reads (bank over the -// bridge + the processor's performance map) with the same off-audio-thread accessors — no -// new locks visible to process, read-only over the bank. Windows-only (D5), guarded so a -// non-Windows build stays compilable. -// -// The strip's LAYOUT + HIT-TEST is pure (embed_strip.h, unit-tested); this shell marshals -// REAPER's messages to/from it and draws with the same LICE idiom as reasampler_editor. +// reasampler_embed.h — the embedded TCP/MCP UI shell. Implements REAPER's +// IReaperUIEmbedInterface so the instrument draws a compact keymap/level strip inline in +// the track/mixer control panel. All embed messages arrive on REAPER's UI thread; nothing +// here runs in process(). Windows-only, guarded so a non-Windows build stays compilable. +// The strip's layout + hit-test is pure (embed_strip.h, unit-tested); this shell marshals +// REAPER's messages to/from it. #pragma once @@ -51,27 +26,30 @@ namespace reasampler::vst { class ReaSamplerProcessor; -// Real-namespace-home using-declarations (Q-W6: the namespaces.h shim is retired). using instrument::map::PerformanceMap; using instrument::map::SampleChoice; -// Implements IReaperUIEmbedInterface. Lifetime is OWNED by the processor (the processor -// holds the sole unique_ptr and hands out AddRef'd references from queryInterface); the -// back-pointer to the processor is therefore always valid while this lives. +// Implements IReaperUIEmbedInterface. Lifetime is owned by the processor (sole unique_ptr, +// hands out AddRef'd references from queryInterface); the back-pointer to the processor is +// therefore always valid while this lives. class ReaSamplerEmbed : public Steinberg::IReaperUIEmbedInterface { public: explicit ReaSamplerEmbed(ReaSamplerProcessor* processor) : processor_(processor) {} - // The one embed entry point. Routes each REAPER_FXEMBED_WM_* message; see the header - // note above for the per-message contract. UI thread only. + // The one embed entry point, verified against reaper_plugin_fx_embed.h + + // reaper_vst3_interfaces.h: our IEditController answers queryInterface for + // IReaperUIEmbedInterface. msg is a REAPER_FXEMBED_WM_* value (aliased to Win32 WM_*) + // — WM_IS_SUPPORTED, WM_CREATE/WM_DESTROY, WM_PAINT (parm2 = IBitmap*, parm3 = + // DrawInfo*), WM_GETMINMAXINFO (parm3 = SizeHints*), mouse WM_* (return + // RETNOTIFY_INVALIDATE to force a redraw). No plugin-owned HWND here (unlike the + // IPlugView editor): REAPER hands a LICE bitmap per paint. UI thread only. Steinberg::TPtrInt embed_message(int msg, Steinberg::TPtrInt parm2, Steinberg::TPtrInt parm3) override; - // FUnknown: this object's lifetime is owned by the processor, not the host refcount, so - // AddRef/release are no-ops (the processor's unique_ptr governs destruction) and - // queryInterface answers only FUnknown + IReaperUIEmbedInterface. This mirrors how the - // SDK's OBJ refcount would otherwise churn; here the owning processor guarantees the - // object outlives every borrowed reference REAPER holds during embedding. + // FUnknown: lifetime is owned by the processor, not the host refcount, so + // AddRef/release are no-ops and queryInterface answers only FUnknown + + // IReaperUIEmbedInterface — the owning processor guarantees this outlives every + // borrowed reference REAPER holds during embedding. Steinberg::tresult PLUGIN_API queryInterface(const Steinberg::TUID iid, void** obj) override; Steinberg::uint32 PLUGIN_API addRef() override { return 1000; } @@ -79,37 +57,29 @@ public: private: #ifdef _WIN32 - // Draw the current strip into REAPER's supplied LICE bitmap. Returns true if it drew. + // Draws the current strip into REAPER's supplied LICE bitmap. Returns true if it drew. bool paint(Steinberg::TPtrInt bitmap, Steinberg::TPtrInt drawInfo); - // Handle a mouse-down inside the strip: map to a zone and select it (S6: selection at - // most — no new editing semantics). Returns true if the selection changed (the caller - // then asks REAPER to invalidate). + // A mouse-down inside the strip: maps to a zone and selects it (no new editing + // semantics). Returns true if the selection changed (caller then invalidates). bool onMouseDown(Steinberg::TPtrInt drawInfo); #endif - // Snapshot the live bank + the instrument's performance map for the next paint, exactly - // as the editor's refreshSampleList does (bridge read + processor accessors, UI thread). + // Snapshots the live bank + the instrument's performance map for the next paint. void refresh(); - // The S9 dirty-guard over refresh() (the S6 flagged follow-up): read the cheap bank- - // generation stamp; do the EXPENSIVE bank-blob bridge read (refresh()) only when the - // generation changed since the last paint (or on the first paint) — the strip re-read - // per paint was wasteful now that a generation counter exists. The performance map (a - // cheap in-process accessor, edited by the editor independently of bank content) is - // ALWAYS refreshed so a zone edit still reflects immediately. UI thread only. + // Dirty-guard over refresh(): re-reads the bank blob only when the (cheap) generation + // stamp changed since the last paint. The performance map is always refreshed (cheap + // in-process accessor) so a zone edit reflects immediately. UI thread only. void maybeRefresh(); ReaSamplerProcessor* processor_ = nullptr; - // The bank generation last folded into samples_ (S9 dirty-guard). -1 forces the first - // maybeRefresh() to do a full read (no generation can be negative — parseBankGeneration - // yields >= 0 — so -1 is an "unprimed" sentinel distinct from a real generation 0). + // The bank generation last folded into samples_. -1 is an "unprimed" sentinel distinct + // from a real generation 0, forcing the first maybeRefresh() to do a full read. std::int64_t lastSeenBankGeneration_ = -1; - // Snapshotted for the current paint (refreshed each paint off the audio thread). std::vector samples_; PerformanceMap map_; - // The zone the last click selected (local/visual only — S6 selection constraint; the - // processor's editor-shared selection is NOT updated from here); -1 = none. - // Drives the strip's highlight. + // The zone the last click selected (local/visual only); -1 = none. Drives the strip's + // highlight. int selectedZone_ = -1; }; diff --git a/src/shell/instrument/reasampler_processor.cpp b/src/shell/instrument/reasampler_processor.cpp index 8f11876..bf5f14c 100644 --- a/src/shell/instrument/reasampler_processor.cpp +++ b/src/shell/instrument/reasampler_processor.cpp @@ -1,10 +1,9 @@ -// reasampler_processor.cpp — see reasampler_processor.h. Since Q-W2v (T4-12) this TU is -// the VST3 LIFECYCLE + the REAL-TIME process() path ONLY: factory/queryInterface, -// initialize/terminate/setActive, bus setup, and the block render (MIDI marshal, preview -// mailbox drain, engine + drain sum, master-gain ramp). Component-state I/O + parameter -// accessors live in processor_state.cpp; the off-thread reload/publish family lives in -// processor_reload.cpp. process() and its per-block work stay ONE TU (T4-29): no virtual -// seam, no cross-TU call on the per-sample path. +// reasampler_processor.cpp — see reasampler_processor.h. This TU is the VST3 lifecycle + +// the real-time process() path only: factory/queryInterface, initialize/terminate/ +// setActive, bus setup, and the block render. Component-state I/O + parameter accessors +// live in processor_state.cpp; the off-thread reload/publish family lives in +// processor_reload.cpp. process() and its per-block work stay one TU on purpose — no +// virtual seam, no cross-TU call on the per-sample path. #include "shell/instrument/reasampler_processor.h" @@ -17,8 +16,8 @@ #include "pluginterfaces/vst/ivstmidicontrollers.h" // kCtrlAllNotesOff / kCtrlAllSoundsOff (panic) #include "pluginterfaces/vst/vstspeaker.h" -#include "shell/instrument/reasampler_editor.h" // createView hands the host our IPlugView editor -#include "shell/instrument/reasampler_embed.h" // S6 embed shell + IReaperUIEmbedInterface (its iid DEF'd there) +#include "shell/instrument/reasampler_editor.h" // createView hands the host our IPlugView editor +#include "shell/instrument/reasampler_embed.h" // embed shell + IReaperUIEmbedInterface (its iid DEF'd there) using namespace Steinberg; using namespace Steinberg::Vst; @@ -27,20 +26,15 @@ namespace reasampler::vst { namespace { -// FB1 post-mixer gain ramp TIME (wall-clock). gainCurrent_ converges to masterGain_ by a -// linear per-sample step derived from this at setupProcessing (gainRampStep_ = -// 1 / (kGainRampSeconds * sampleRate_)) — the kPreserveWindowMs pattern, per the standing -// no-hardcoded-rate ruling (Q-W0 T3-01; the prior constant baked 20 ms x 48 kHz in as -// 1/960, silently shortening the ramp at higher host rates). A full 0-to-unity ramp is -// ~20 ms at EVERY host rate; the snap threshold (half a step, below which gainCurrent_ -// jumps to the target) avoids long sub-LSB creep and the ramp loop on idle blocks. +// Post-mixer gain ramp time (wall-clock): gainRampStep_ = 1/(kGainRampSeconds * +// sampleRate_), per the no-hardcoded-rate ruling — ~20 ms full ramp at every host rate. constexpr double kGainRampSeconds = 0.020; } // namespace FUnknown* ReaSamplerProcessor::createInstance(void* /*context*/) { - // The host owns the returned reference. Cast up to the combined interface the SDK - // exposes (IAudioProcessor) so the FUnknown refcount is correctly rooted. + // The host owns the returned reference; cast to IAudioProcessor so the FUnknown + // refcount is correctly rooted. return static_cast(new ReaSamplerProcessor()); } @@ -48,10 +42,8 @@ FUnknown* ReaSamplerProcessor::createInstance(void* /*context*/) { ReaSamplerProcessor::~ReaSamplerProcessor() = default; tresult PLUGIN_API ReaSamplerProcessor::queryInterface(const TUID iid, void** obj) { - // S6: expose REAPER's inline-embed interface. REAPER queries the IEditController for - // IReaperUIEmbedInterface (reaper_vst3_interfaces.h); hand it our lazily-created embed - // shell. We own the shell (unique_ptr); the borrowed reference is valid because the - // processor outlives it. All other iids fall through to the SDK's queryInterface. + // REAPER queries the IEditController for IReaperUIEmbedInterface; hand it our + // lazily-created embed shell (the processor outlives the borrowed reference). if (FUnknownPrivate::iidEqual(iid, IReaperUIEmbedInterface::iid)) { if (!embed_) embed_ = std::make_unique(this); embed_->addRef(); @@ -69,17 +61,11 @@ tresult PLUGIN_API ReaSamplerProcessor::initialize(FUnknown* context) { // instrument still loads, it just has no live bank to play. bridge_.connect(context); - // Instrument bus topology: one event input (MIDI in, 16 channels), one audio output, no - // audio input. GA fix (hard-right pan): the output bus is a FIXED STEREO bus regardless of - // the channel mode. The mode is a DECODE policy (downmix vs L/R split) — mono mode renders - // dual-mono through the stereo bus (both channels equal, centered), which is audibly - // identical to a mono bus but never asks the host to re-map a live instance's pins. The - // prior design flipped the bus kMono<->kStereo via restartComponent(kIoChanged) on every - // mode change/restore; in the DAW that flip panned a dual-mono capture hard RIGHT. The - // in-plugin path is provably symmetric (decode, per-voice stereo render, engine sum, buffer - // write — see testDualMonoStereoSampleRendersCentered), so the asymmetry sat in the host's - // re-routing of the live instance's pins across the arrangement change. A fixed arrangement - // is the maximally-standard VSTi shape and removes that whole negotiation surface. + // One event input (MIDI, 16 channels), one audio output, no audio input. The output + // bus is fixed stereo regardless of channel mode (mono renders dual-mono, centered). + // Do not reintroduce per-mode bus renegotiation: flipping kMono<->kStereo via + // restartComponent previously panned a dual-mono capture hard right in the host's pin + // re-routing (see testDualMonoStereoSampleRendersCentered). addEventInput(STR16("MIDI In"), 16); addAudioOutput(STR16("Audio Out"), SpeakerArr::kStereo); @@ -87,9 +73,8 @@ tresult PLUGIN_API ReaSamplerProcessor::initialize(FUnknown* context) { } tresult PLUGIN_API ReaSamplerProcessor::terminate() { - // process() is not running at terminate: free the live + draining instruments and - // drain the graveyard. Take the pointers out of the atomics first so nothing else - // races them. + // process() is guaranteed stopped at terminate: free the live + draining instruments + // and drain the graveyard. std::lock_guard lock(reloadMutex_); delete live_.exchange(nullptr); delete draining_.exchange(nullptr); @@ -98,33 +83,24 @@ tresult PLUGIN_API ReaSamplerProcessor::terminate() { } tresult PLUGIN_API ReaSamplerProcessor::setActive(TBool state) { - // Activating: build the instrument from the currently-selected sample so the first - // block after activation can play. Deactivating: process is now GUARANTEED stopped by - // the host, so this is the safe point to reclaim the graveyard (the displaced engines - // no reload could free while active). The build/drain are off the audio thread — - // setActive is a main/UI-thread call. + // Activating: build from the currently-selected sample so the first block after + // activation can play. Deactivating: process is now guaranteed stopped, so this is + // the safe point to reclaim the graveyard. Main/UI-thread call. if (state) { - // Self-contained (pS): this rebuild resolves + decodes from the instance-OWNED - // sample refs — it needs no bank read, so it plays regardless of whether the - // extension's PROJEXTSTATE has parsed yet (or the extension exists at all). - // - // #B: this unconditional rebuild is ALSO the NON-editor legacy trigger for a - // pre-v10 blob (refs empty + intent): reloadInstrument's opportunistic - // refreshRefsFromBank copies the refs in when the bank blob is readable by - // activation time, so an upgraded project plays on load without the instrument - // ever being opened (and the next save is self-contained). Residual load-order - // race, DAW-verifiable only: if the host activates this instance BEFORE the - // project's ext-state lines parse, the lift misses here and — with no editor open — - // nothing retries until the next activation or editor tick. MIGRATION NOTE: open a - // pre-v10 instrument once after upgrading if it restores silent. + // Resolves + decodes from the instance-owned refs — no bank read needed, so it + // plays regardless of PROJEXTSTATE parse state. Also doubles as the non-editor + // legacy-lift trigger for a pre-v10 blob: reloadInstrument's opportunistic + // refreshRefsFromBank copies refs in when the bank blob is readable by now. + // Residual load-order race (DAW-verifiable only): if the host activates before the + // project's ext-state parses, nothing retries until the next activation or editor + // tick — open a pre-v10 instrument once after upgrading if it restores silent. reloadInstrument(); } else { std::lock_guard lock(reloadMutex_); - // process is guaranteed stopped: free EVERYTHING. The live instrument too — its - // voices are frozen mid-flight, and if it survived deactivation the reactivate - // reload would displace it into the DRAIN slot, resurrecting stale sustained - // voices as ghosts. Reactivation rebuilds from scratch (reloadInstrument above), - // so nothing is lost by clearing here. + // Free EVERYTHING, including live_: its voices are frozen mid-flight, and if it + // survived deactivation the reactivate reload would displace it into the drain + // slot, resurrecting stale sustained voices as ghosts. Reactivation rebuilds from + // scratch above, so nothing is lost. delete live_.exchange(nullptr); delete draining_.exchange(nullptr); graveyard_.clear(); @@ -135,9 +111,8 @@ tresult PLUGIN_API ReaSamplerProcessor::setActive(TBool state) { tresult PLUGIN_API ReaSamplerProcessor::setupProcessing(ProcessSetup& setup) { sampleRate_ = setup.sampleRate; maxBlockSize_ = setup.maxSamplesPerBlock; - // T3-01: resolve the FB1 gain-ramp step against the live host rate (20 ms wall-clock at - // every rate). At 48 kHz this is exactly the former 1/960 constant. Written here (host - // guarantees setupProcessing never overlaps process), read on the audio thread only. + // Resolve the gain-ramp step against the live host rate (host guarantees + // setupProcessing never overlaps process). if (sampleRate_ > 0.0) { gainRampStep_ = static_cast(1.0 / (kGainRampSeconds * sampleRate_)); } @@ -147,11 +122,10 @@ tresult PLUGIN_API ReaSamplerProcessor::setupProcessing(ProcessSetup& setup) { tresult PLUGIN_API ReaSamplerProcessor::setBusArrangements( SpeakerArrangement* inputs, int32 numIns, SpeakerArrangement* outputs, int32 numOuts) { - // ONE canonical arrangement: the fixed stereo output bus (GA fix — the channel mode is a - // decode policy, never a bus fact). We take NO audio input, so any inputs are rejected. - // Accept (kResultTrue) only a single stereo output proposal; otherwise reject (kResultFalse) - // and keep our stereo arrangement (per the VST3 contract, a plug-in that can't honor a - // proposal keeps a valid arrangement of its own) — the host adapts its routing to us. + // Fixed stereo output bus (channel mode is a decode policy, never a bus fact); no audio + // input, so any inputs are rejected. Accept only a single stereo output proposal; + // otherwise reject and keep stereo (per the VST3 contract, a plug-in that can't honor a + // proposal keeps a valid arrangement of its own) — the host adapts to us. if (numIns < 0 || numOuts < 0) return kInvalidArgument; if (numIns > 0) return kResultFalse; // no audio input bus to arrange if (numOuts == 1 && outputs && outputs[0] == SpeakerArr::kStereo) return kResultTrue; @@ -159,23 +133,19 @@ tresult PLUGIN_API ReaSamplerProcessor::setBusArrangements( } tresult PLUGIN_API ReaSamplerProcessor::process(ProcessData& data) { - // REAL-TIME: no allocation, no IO, no locks. Load the live AND draining instruments - // once for the whole block (two atomic acquires), then publish the MINIMUM installedAt - // over the pointers held so the off-thread graveyard pruner knows exactly which - // generations this block is holding (see the header proof). + // Real-time: no allocation, no IO, no locks. Load live + draining once for the whole + // block (two atomic acquires), then publish the minimum installedAt over the pointers + // held so the off-thread graveyard pruner knows which generations this block holds (see + // the header's drain-slot proof). We publish installedAt rather than re-reading + // reloadGeneration_ to close an ordering race: a fresh read could observe a generation + // newer than the pointers actually held, letting the pruner free an instrument still + // in use. // - // We publish installedAt — not a fresh re-read of reloadGeneration_ — to close an - // ordering race: reading reloadGeneration_ after the slots could observe a generation - // newer than the pointers we actually hold, causing the pruner to free an instrument - // process is still reading. installedAt was set on the reload path before the atomic - // exchange that made the instrument visible. - // - // The DRAIN instrument (FA1, bug 3b) is the previously-live snapshot displaced by the - // last reload: its already-sounding voices keep rendering (and receive note-offs) so a - // curve/param edit or bank refresh never cuts a ringing note. It receives NO note-ons. - // A racing reload can briefly leave the same pointer in both slots (live_ was loaded - // before the swap, draining_ after); collapse that to live-only so one engine is never - // advanced twice per frame. + // The drain instrument is the previously-live snapshot displaced by the last reload: + // its already-sounding voices keep rendering (and receive note-offs) so an edit never + // cuts a ringing note; it receives no note-ons. A racing reload can briefly leave the + // same pointer in both slots (live_ loaded before the swap, draining_ after); collapse + // that to live-only so one engine is never advanced twice per frame. LoadedInstrument* inst = live_.load(std::memory_order_acquire); LoadedInstrument* drain = draining_.load(std::memory_order_acquire); if (drain == inst) drain = nullptr; @@ -190,20 +160,16 @@ tresult PLUGIN_API ReaSamplerProcessor::process(ProcessData& data) { } processGeneration_.store(heldGen, std::memory_order_release); - // Phase S drain retirement: publish whether the drain snapshot is FULLY idle (every engine - // voice silent) by naming its OWN installedAt (0 = no drain / still - // sounding). Evaluated at block START — idleness is monotone for a drain (it receives no - // note-ons), so a snapshot observed idle here stays idle; a tail that dies mid-block simply - // publishes one block later. Bounded scan (<= maxVoices), relaxed store — RT-safe. + // Publish whether the drain snapshot is fully idle, naming its own installedAt (0 = no + // drain / still sounding). Idleness is monotone for a drain (no note-ons), so a + // snapshot observed idle here stays idle. Bounded scan, relaxed store — RT-safe. drainIdleGeneration_.store( (drain && drain->fullyIdle()) ? drain->installedAt : 0, std::memory_order_relaxed); - // Marshal MIDI note-on/off from the event input into the voice engine. Tier 0 maps - // events at block granularity (no per-event sample-offset split) — audible timing is - // within one block, adequate for Tier 0; sample-accurate scheduling is a later tier. - // Note-offs also route to the DRAIN engine so a note held across a reload releases - // its old-snapshot voice too (otherwise it would sustain until the next reload). + // Marshal MIDI note-on/off at block granularity (no per-event sample-offset split; + // sample-accurate scheduling is a later tier). Note-offs also route to the drain + // engine so a note held across a reload releases its old-snapshot voice too. if (data.inputEvents) { const int32 count = data.inputEvents->getEventCount(); for (int32 i = 0; i < count; ++i) { @@ -222,18 +188,11 @@ tresult PLUGIN_API ReaSamplerProcessor::process(ProcessData& data) { if (inst) inst->engine.noteOff(e.noteOff.pitch); if (drain) drain->engine.noteOff(e.noteOff.pitch); } else if (e.type == Event::kLegacyMIDICCOutEvent) { - // PANIC (Phase S voice-review Major #2): REAPER delivers raw input MIDI CC to a - // VST3 instrument as kLegacyMIDICCOut events on the INPUT event list (a REAPER-ism - // — the type is nominally an output event; DAW-verify, see handoff). - // CC 123 (All Notes Off): release semantics — Gate voices enter their AHDSR - // release tail; Trigger one-shots play through their bounded play length. - // CC 120 (All Sounds Off): hard-stop semantics — immediate silence regardless - // of play mode, including Trigger one-shots that ignore CC 123. This is the - // true "panic" for a ringing one-shot (e.g. a full-length capture). - // Both clear the mono held stack. Both apply to live AND drain. A ringing - // preview note is a real engine voice since the PreviewCard retirement, so - // the panics cover it with no separate routing. allNotesOff / allSoundsOff - // are RT-safe (no allocation, bounded scans). + // Panic: REAPER delivers raw input MIDI CC as kLegacyMIDICCOut events on the + // INPUT event list (a REAPER-ism, DAW-verified). CC 123 (All Notes Off): + // release semantics (Gate -> release tail; Trigger plays through). CC 120 + // (All Sounds Off): immediate hard silence, including Trigger. Both apply to + // live + drain and cover a ringing preview note. const auto cc = static_cast(e.midiCCOut.controlNumber); if (cc == kCtrlAllSoundsOff) { if (inst) inst->engine.allSoundsOff(); @@ -246,16 +205,11 @@ tresult PLUGIN_API ReaSamplerProcessor::process(ProcessData& data) { } } - // S-VIEW-4 preview mailbox: drain the off-thread preview-trigger requests (a single relaxed - // atomic load each — RT-safe). A request is NEW when its packed sequence differs from the last - // one we consumed; fire it once, then latch the sequence so the same request never re-fires. - // Preview redesign: the drained requests drive the MAIN VoiceEngine — the exact - // noteOn/noteOff calls the host MIDI marshal above makes — so a preview note is a real - // voice: it counts against the voice count, can steal / be stolen, and respects - // Poly/Mono + Retrigger/Legato (deliberate reversal of the retired PreviewCard's - // isolation). The editor posts the root note, so it plays at unity. - // Consume (advance the sequence) even when inst is null so a note-on posted while no instrument - // is loaded does not re-fire stale on the next instrument load. + // Preview mailbox: drain off-thread preview-trigger requests (one relaxed atomic load + // each). A request is new when its packed sequence differs from the last consumed; fire + // once, then latch the sequence. Drives the main VoiceEngine — same noteOn/noteOff as + // host MIDI, so a preview note is a real voice. Consume even when inst is null so a + // note-on posted while nothing is loaded does not re-fire stale later. { const std::uint32_t on = previewOnRequest_.load(std::memory_order_acquire); const std::uint16_t onSeq = static_cast(on >> 16); @@ -272,16 +226,12 @@ tresult PLUGIN_API ReaSamplerProcessor::process(ProcessData& data) { const std::uint32_t off = previewOffRequest_.load(std::memory_order_acquire); const std::uint16_t offSeq = static_cast(off >> 16); if (offSeq != 0 && offSeq != previewOffConsumed_) { - // Consume UNCONDITIONALLY (mirror of the on path): a stale off left pending - // while nothing was loaded would otherwise survive until a (heal) reload lands - // and release the NEXT preview press in the same block. - previewOffConsumed_ = offSeq; - // Route the preview note-off to BOTH engines (mirror of the host note-off): a - // preview held across a reload — e.g. a curve edit committed mid-press — must - // release the old-snapshot voice now draining, not just the (fresh) live one. - // NOTE: preview shares the host-MIDI note space — noteOff releases the newest - // voice at that pitch, so a preview release can release a host-held note at + // Consume unconditionally (mirror of the on path) so a stale off does not + // survive to release the NEXT preview press. Routes to both engines: a preview + // held across a reload must release the old-snapshot voice too. NOTE: preview + // shares the host-MIDI note space, so a release can release a host-held note at // the same pitch (inherent to routing preview through the real note path). + previewOffConsumed_ = offSeq; if (inst) inst->engine.noteOff(static_cast(off & 0xFF)); if (drain) drain->engine.noteOff(static_cast(off & 0xFF)); } @@ -309,27 +259,22 @@ tresult PLUGIN_API ReaSamplerProcessor::process(ProcessData& data) { return kResultOk; } - // Render per the host's NEGOTIATED output channel count (S7). The channel mode was baked - // into the LoadedInstrument's decode + negotiated onto the output bus off-thread, so here - // we simply match the buffers the host handed us: >=2 channels -> true stereo render into - // ch0/ch1 (then replicate any extra channels); exactly 1 -> the mono render. Either way the - // render ADDS into a cleared buffer — RT-safe (no alloc/IO/lock). NEVER reads the mode here. + // Render per the host's negotiated channel count (mode was baked into the decode + // off-thread, so the mode itself is never read here): >=2 channels -> stereo into + // ch0/ch1 (then mirror extras); exactly 1 -> mono. Adds into a cleared buffer. float* ch0 = out.numChannels > 0 ? out.channelBuffers32[0] : nullptr; float* ch1 = out.numChannels > 1 ? out.channelBuffers32[1] : nullptr; if (ch0 && ch1) { - // Stereo: clear both, render L/R. A mono sample plays dual-mono via the engine's stereo - // path (both channels equal), so a mono capture in stereo mode is centered, not silent. - // The DRAIN engine's ringing tails ADD on top (render mixes into the cleared buffer). + // A mono sample plays dual-mono via the engine's stereo path, so a mono capture in + // stereo mode is centered, not silent. The drain engine's ringing tails add on top. for (int32 i = 0; i < frames; ++i) { ch0[i] = 0.f; ch1[i] = 0.f; } if (inst) inst->engine.render(ch0, ch1, static_cast(frames)); if (drain) drain->engine.render(ch0, ch1, static_cast(frames)); - // FB1 post-mixer master gain: ramp gainCurrent_ toward the atomic target per-sample so - // continuous knob drags produce no zipper noise and the true-zero bottom causes no click. - // Applied AFTER the voice sum and BEFORE the extra-channel mirror + peak so both see the - // actual output. Branch-free inner loop; early-out when already at target. RT-safe. + // Post-mixer master gain: ramp gainCurrent_ toward the atomic target per-sample so + // knob drags produce no zipper noise. Early-out when already at target. { const float gTarget = masterGain_.load(std::memory_order_relaxed); - const float gStep = gainRampStep_; // T3-01: rate-derived per-sample step + const float gStep = gainRampStep_; // rate-derived per-sample step const float gSnap = 0.5f * gStep; const float diff = gTarget - gainCurrent_; if (diff < -gSnap || diff > gSnap) { @@ -349,13 +294,13 @@ tresult PLUGIN_API ReaSamplerProcessor::process(ProcessData& data) { } } } - // Any channels beyond the first two mirror ch0 (defensive — REAPER negotiates 1 or 2). + // Channels beyond the first two mirror ch0 (defensive — REAPER negotiates 1 or 2). for (int32 ch = 2; ch < out.numChannels; ++ch) { if (float* buf = out.channelBuffers32[ch]) { for (int32 i = 0; i < frames; ++i) buf[i] = ch0[i]; } } - // Block peak (max across L/R) for the embed strip's level indicator; RT-safe. + // Block peak (max across L/R) for the embed strip's level indicator. float peak = 0.f; for (int32 i = 0; i < frames; ++i) { const float a0 = ch0[i] < 0.f ? -ch0[i] : ch0[i]; @@ -365,16 +310,14 @@ tresult PLUGIN_API ReaSamplerProcessor::process(ProcessData& data) { } embedPeak_.store(peak, std::memory_order_relaxed); } else if (ch0) { - // Mono: render into channel 0, replicate to any extra channels (mono bus is 1 channel; - // the replicate is defensive for a host that still hands >1 channel on a mono bus). + // Mono: render into channel 0, replicate to any extra channels (defensive). for (int32 i = 0; i < frames; ++i) ch0[i] = 0.f; if (inst) inst->engine.render(ch0, static_cast(frames)); if (drain) drain->engine.render(ch0, static_cast(frames)); - // FB1 post-mixer master gain (mono path) — same ramp contract as the stereo branch: - // post-sum, pre-peak/replicate, per-sample gainCurrent_ ramp toward target, RT-safe. + // Same gain-ramp contract as the stereo branch above. { const float gTarget = masterGain_.load(std::memory_order_relaxed); - const float gStep = gainRampStep_; // T3-01: rate-derived per-sample step + const float gStep = gainRampStep_; // rate-derived per-sample step const float gSnap = 0.5f * gStep; const float diff = gTarget - gainCurrent_; if (diff < -gSnap || diff > gSnap) { @@ -405,9 +348,8 @@ tresult PLUGIN_API ReaSamplerProcessor::process(ProcessData& data) { } } - // Report silence only when nothing is loaded (lets the host optimize when idle). - // With an instrument loaded — or a drain snapshot still ringing out — we clear the - // flag so a ringing voice is not skipped. + // Report silence only when nothing is loaded (lets the host optimize when idle); with + // a drain snapshot still ringing out, clear the flag so it is not skipped. out.silenceFlags = (inst || drain) ? 0 : ((out.numChannels >= 64) ? ~0ULL diff --git a/src/shell/instrument/reasampler_processor.h b/src/shell/instrument/reasampler_processor.h index 3c01c6c..d5bb9da 100644 --- a/src/shell/instrument/reasampler_processor.h +++ b/src/shell/instrument/reasampler_processor.h @@ -1,28 +1,9 @@ -// reasampler_processor.h — the VST3 SingleComponentEffect (Phase S4, Tier 0). Wires the -// pure S3 sampler core into a real VSTi: it declares an event-input bus + a stereo audio -// output bus, marshals host MIDI note-on/off into the VoiceEngine, and renders the -// engine's audio into the output bus — so a chosen bank sample plays chromatically from -// its root note in REAPER's routing/record/render path. -// -// SingleComponentEffect is the SDK's combined processor+controller base — sanctioned -// for a non-distributable, REAPER-only plugin under D5/D6. It gives us -// addAudioOutput/addEventInput, IComponent setState/getState for the instance's own -// state (the selected sample), and the IEditController seat so createView() can hand the -// host our IPlugView LICE editor. -// -// SELF-CONTAINED PLAYBACK (pS architecture correction). The instance OWNS its sample: the -// component state persists, per referenced bank sample, the project-relative WAV path + -// decode intrinsics (SampleRefs), and reloadInstrument decodes straight from that table. -// The extension's bank blob is a BROWSER SOURCE that opportunistically refreshes the refs -// when readable — NEVER a runtime requirement for playback. A project restored before the -// extension's PROJEXTSTATE parses (or with the extension absent) plays on load; the old -// reopen-heal timer + poll-to-play machinery that papered over the bank dependency is gone. -// -// REAL-TIME DISCIPLINE (S4 hard constraint). The audio thread (process) does NO -// allocation, NO file I/O, NO bridge calls, NO locks. Sample loading — ref resolve, WAV -// decode, keymap build, VoiceEngine construction — all happens OFF the audio thread -// (reloadInstrument, driven from the main/UI thread) and is handed to process via a -// single atomic pointer swap. See the LoadedInstrument handoff below. +// reasampler_processor.h — VST3 SingleComponentEffect wiring the pure sampler core into +// a playable instrument: event-input + stereo output bus, MIDI -> VoiceEngine, render. +// Self-contained playback: component state owns per-sample WAV path + decode intrinsics +// (SampleRefs); the bank blob is an opportunistic browser source, never a playback +// dependency. Audio thread (process()) does no allocation/file-IO/bridge calls/locks; +// loading happens off-thread (reloadInstrument) and hands off via one atomic pointer swap. #pragma once @@ -42,37 +23,24 @@ namespace reasampler::vst { -// Cross-subsystem deps by their real namespace homes (Q-W2v: the core/namespaces.h shim -// is retired from the processor family; the engine family's symbols — Keymap, VoiceEngine, -// ChannelMode, VoiceMode, MonoTrigger, the voice-count constants — still live in flat -// `reasampler` and resolve via the enclosing namespace). using instrument::map::ComponentState; using instrument::map::PerformanceMap; using instrument::map::SampleRefs; using instrument::map::kPreviewVelocityDefault; -class ReaSamplerEmbed; // S6 embedded TCP/MCP UI shell (owned below; see queryInterface) +class ReaSamplerEmbed; // embedded TCP/MCP UI shell (owned below; see queryInterface) -// One fully-built, ready-to-play instrument snapshot: the decoded keymap and the voice -// engine that plays it. The engine holds references into the keymap, so the two MUST live -// and die together at a STABLE address — hence this is heap-allocated and neither copyable -// nor movable. The audio thread only ever reads it through an atomic pointer; it is built -// and destroyed off the audio thread. -// -// installedAt: the reloadGeneration_ value at which this instrument was atomically -// installed into live_. Set on the reload path before the exchange. process() publishes -// this field (not a fresh re-read of reloadGeneration_) so the published generation is -// exactly the generation of the instrument actually in hand for the block. +// Decoded keymap + the voice engine playing it. The engine holds references into the +// keymap, so both must live/die together at a stable address — heap-allocated, +// non-copyable, non-movable. process() only ever reads this through an atomic pointer. struct LoadedInstrument { Keymap keymap; VoiceEngine engine; - std::uint64_t installedAt = 0; // reload generation at which this was installed + std::uint64_t installedAt = 0; // reloadGeneration_ at which this was installed into live_ - // The takeover declick (GA fix, rev 2) is opted IN here — the PRODUCT default: any - // restart of a sounding voice (mono Retrigger takeover/fallback, cross-sample legato - // restart, POLY at-cap steal — the preview note included, now that it is a real pool - // voice) smooths the cut via the difference-seeded ramp instead of clicking. The pure - // core defaults it off (regression baseline) — same layering as kDefaultPitchEngine. + // Takeover declick is on by default here (product default; the pure core defaults it + // off): any voice restart (mono retrigger, legato, poly steal, preview) ramps instead + // of clicking. LoadedInstrument(Keymap km, std::size_t maxVoices, std::uint64_t gen, std::size_t preserveVoiceCap = 0, std::int64_t preserveWindowFrames = 0, @@ -83,9 +51,8 @@ struct LoadedInstrument { voiceMode, monoTrigger, /*takeoverDeclick=*/true), installedAt(gen) {} - // True when nothing in this snapshot is sounding. process() publishes this for the - // drain slot so the off-thread retirer can park an idle drain in the graveyard early - // (FA1-review Major #2). Bounded scan (<= maxVoices). + // True when nothing in this snapshot is sounding; lets the off-thread retirer park an + // idle drain early. Bounded scan (<= maxVoices). bool fullyIdle() const { return engine.activeVoiceCount() == 0; } LoadedInstrument(const LoadedInstrument&) = delete; @@ -95,8 +62,8 @@ struct LoadedInstrument { class ReaSamplerProcessor : public Steinberg::Vst::SingleComponentEffect { public: ReaSamplerProcessor() = default; - // Out-of-line so the owned ReaSamplerEmbed (held by unique_ptr, forward-declared here) - // is a complete type at the destruction point (defined in the .cpp). + // Out-of-line so the owned ReaSamplerEmbed (unique_ptr, forward-declared here) is + // complete at the destruction point (defined in the .cpp). ~ReaSamplerProcessor() override; // The factory create function (registered in vst_entry.cpp). @@ -109,9 +76,9 @@ public: Steinberg::tresult PLUGIN_API terminate() override; Steinberg::tresult PLUGIN_API setActive(Steinberg::TBool state) override; - // Instance state = the selected bank sample id (D-B: a performance choice the - // instrument owns; NEVER written back to the bank). Component-state, so a saved - // REAPER project restores which sample each instance plays. + // Instance state = the selected bank sample id (a performance choice the instrument + // owns; never written back to the bank). Component-state, so a saved project restores + // which sample each instance plays. Steinberg::tresult PLUGIN_API setState(Steinberg::IBStream* state) override; Steinberg::tresult PLUGIN_API getState(Steinberg::IBStream* state) override; @@ -122,11 +89,9 @@ public: Steinberg::tresult PLUGIN_API process( Steinberg::Vst::ProcessData& data) override; - // Output-bus negotiation. The instrument has ONE canonical output arrangement: a FIXED - // stereo bus (GA fix — the channel mode is a decode policy, never a bus fact; mono mode - // renders dual-mono through it). We accept the host's proposal only when it is a single - // stereo output; otherwise we reject (kResultFalse) but keep our stereo arrangement, so - // getBusArrangement / getBusInfo always report 2 channels and the host routes accordingly. + // Fixed stereo output bus — channel mode is a decode policy, never a bus fact; mono + // renders dual-mono through it. Do not reintroduce per-instance bus renegotiation. + // Accepts only a single stereo output proposal; otherwise rejects and keeps stereo. Steinberg::tresult PLUGIN_API setBusArrangements( Steinberg::Vst::SpeakerArrangement* inputs, Steinberg::int32 numIns, Steinberg::Vst::SpeakerArrangement* outputs, Steinberg::int32 numOuts) override; @@ -135,107 +100,73 @@ public: // Hands the host our LICE IPlugView editor. Steinberg::IPlugView* PLUGIN_API createView(Steinberg::FIDString name) override; - // Override queryInterface to additionally expose REAPER's IReaperUIEmbedInterface (S6): - // REAPER queries the IEditController for it to drive the inline TCP/MCP embed surface. - // All other iids delegate to SingleComponentEffect's implementation unchanged. + // Additionally exposes REAPER's IReaperUIEmbedInterface (queried by REAPER to drive the + // inline TCP/MCP embed); all other iids delegate to SingleComponentEffect unchanged. Steinberg::tresult PLUGIN_API queryInterface(const Steinberg::TUID iid, void** obj) override; - // The embedded-strip activity level (0..1), read by the S6 embed shell on the UI thread. - // Backed by embedPeak_, the per-block mono peak the audio thread stores relaxed — a - // lock-free advisory readout, never touched with a lock the audio thread could contend. + // The embedded-strip activity level (0..1) for the embed shell, UI thread. Backed by + // embedPeak_, a lock-free relaxed atomic the audio thread writes each block. double embedActivityLevel() const { return static_cast(embedPeak_.load(std::memory_order_relaxed)); } - // Called by the editor (main/UI thread) when the user picks a sample, and internally - // on load. SELF-CONTAINED (pS): resolves the selection/zones against the instance-OWNED - // SampleRefs table, decodes each WAV OFF the audio thread, and publishes the built - // instrument to process() via an atomic swap — NO bank read is required for playback. - // When the live bank blob IS readable it is first folded into the refs table - // (refreshRefsFromBank), which is both the browser's copy-the-ref-in mechanism and the - // S9 live-recapture sync. A missing/unreadable WAV is the defined no-play (silence, no - // retry). Returns the resolved selection id ("" if nothing was loaded) for the editor. + // Resolves selection/zones against the instance-owned SampleRefs, decodes each WAV + // off-thread, and publishes the built instrument via atomic swap — no bank read + // required. When the bank blob is readable it's first folded into the refs table + // (refreshRefsFromBank; the browser's copy-the-ref-in + recapture-sync mechanism). A + // missing/unreadable WAV is the defined no-play (silence, no retry). Returns the + // resolved selection id ("" if nothing loaded). std::string reloadInstrument(); - // The result of a bank-sync poll (S9/S8): what pollBankSync did this tick, so the editor - // can react (repaint / re-snapshot its own view) only when something actually changed. + // What pollBankSync did this tick, so the editor can react only when something changed. struct BankSyncResult { - // The bank generation changed (or a pre-v10 legacy lift landed an instrument) -> - // reloadInstrument ran and the editor should re-snapshot its bank view. - bool reloaded = false; + bool reloaded = false; // bank generation changed (or a legacy lift landed) -> reloaded bool applied = false; // a new assignment request was applied -> selection changed }; - // Poll the S9 bank-generation counter and the S8 assignment request over the bridge, OFF - // THE AUDIO THREAD (the editor's UI timer drives this — NEVER process()). This is an - // EDITOR/BROWSER sync path — playback never depends on it (pS). Semantics: - // * S9: if the bank generation differs from what we last saw, call reloadInstrument() so - // a recapture/ingest refreshes playback hands-free (atomic swap, glitch-free). - // * S8: if a NEW (generation > last consumed) assignment request names a resolvable - // sample AND this instance is the target (isFocusedTarget), apply it as the selection - // and reload; an unresolvable request is DROPPED silently (marker advanced, no change); - // a non-target instance neither applies nor advances its marker. - // * LEGACY LIFT: a pre-v10 blob restored with intent but no refs retries the (cheap) - // bank read until the blob is parseable, then reloads ONCE to copy the refs in. - // TERMINATING: once the blob parses and NO referenced id resolves, the ids are - // provably stale — the lift concludes permanently (legacyLiftShouldRun) instead of - // churning a full bank read + reload every tick forever. - // The consumed marker advances in component state (marked dirty via the host handler) so a - // re-open does not re-apply. `isFocusedTarget` is the shell's thundering-herd policy input - // (the editor passes true only for the instance whose editor is open — see the handoff). - // Idempotent on an idle tick (generation unchanged + no new request -> no work). + // Off-thread poll (editor's UI timer only) of the bank generation + assignment request; + // playback never depends on it. Generation change -> reload; a resolvable NEW assignment + // targeting this instance (isFocusedTarget) -> apply as selection + reload (unresolvable + // ones drop silently, marker still advances); pre-v10 legacy blobs retry the bank read + // until the refs lift in, then stop (legacyLiftShouldRun). The consumed marker persists + // so a re-open does not re-apply. Idempotent on an idle tick. BankSyncResult pollBankSync(bool isFocusedTarget); // The bridge, for the editor's live-state readout + sample list. Owned here; the // editor borrows it (outlives the editor). ReaperBridge& bridge() { return bridge_; } - // The live host sample rate latched from setupProcessing (the SAME rate reloadInstrument - // resolves seconds->frames against). The editor's S-VIEW-3 envelope overlay reads it to place - // its wall-clock seconds on the same time base the voice engine plays them over. 0.0 before - // setupProcessing runs (the editor guards). Read on the UI thread; a plain load — sampleRate_ - // is set once by setupProcessing before any audio and does not change under the editor. + // The live host sample rate latched from setupProcessing; the editor's envelope overlay + // shares this time base. 0.0 before setupProcessing runs. double sampleRate() const { return sampleRate_; } - // The current single-capture selection id (main/UI thread reads for the editor). Guarded - // by selectionMutex_ — never touched on the audio thread. Since S10 this is the ONE picked - // capture the default face plays chromatically when the performance map is empty; an EMPTY - // id resolves to SILENCE (no first-sample fallback). A non-empty zoned map supersedes it. + // The single-capture selection id (guarded by selectionMutex_, never read on the audio + // thread): the default face's pick when the performance map is empty; a non-empty map + // supersedes it. Empty id -> silence, no first-sample fallback. std::string selectedSampleId(); void setSelectedSampleId(const std::string& id); - // The performance map (Tier 1: the zoned keymap the instrument owns; D-B). Read/written - // by the editor on the UI thread; snapshotted under performanceMutex_. NEVER read on the - // audio thread — reloadInstrument bakes it into the LoadedInstrument's Keymap off-thread. + // The performance map (zoned keymap). UI thread, guarded by performanceMutex_; never + // read on the audio thread — reloadInstrument bakes it into the Keymap off-thread. PerformanceMap performanceMap(); void setPerformanceMap(const PerformanceMap& map); - // The per-instance channel mode (S7, D-E: mono | stereo). Read/written on the UI thread - // (the editor toggle) and read off-thread by getState/reloadInstrument; guarded by - // channelModeMutex_. NEVER read on the audio thread — process() renders against the host's - // negotiated output channel count, and reloadInstrument bakes the mode into the decode. - // GA fix: the mode is a DECODE policy only (downmix vs L/R split). The output bus is a - // FIXED stereo bus — mono mode renders dual-mono through it (centered) — so a mode change - // never renegotiates host I/O (the mono<->stereo bus flip's live pin remap was the - // hard-right-pan defect). + // Per-instance channel mode (mono | stereo), guarded by channelModeMutex_, never read + // on the audio thread. Decode policy only (downmix vs L/R split) — the output bus is + // fixed stereo, so a mode change never renegotiates host I/O. ChannelMode channelMode(); - // Sets the mode from the EDITOR TOGGLE (a deliberate user choice): latches the mode - // EXPLICIT (the GA auto-default stops fighting it), and on a CHANGE reloads the instrument - // so the next block decodes the new channel count. UI thread only. + // Editor toggle: latches the mode explicit (auto-default stops fighting it) and + // reloads so the next block decodes the new channel count. UI thread only. void setChannelMode(ChannelMode mode); - // The per-instance preview-trigger velocity (S-VIEW-4, MIDI 1..127). Read/written on the - // UI thread (the Sample-view velocity knob) and by getState/setState (host load-save thread); - // guarded by previewMutex_. Persisted in component state (v6). NOT read on the audio thread. + // Per-instance preview-trigger velocity (MIDI 1..127), guarded by previewMutex_, not + // read on the audio thread. std::uint8_t previewVelocity(); void setPreviewVelocity(std::uint8_t velocity); - // --- Phase S voice-system parameters (per-instance, persisted in component state v7) --- - // Read/written on the UI thread (the editor's voice deck) and by getState/setState; guarded - // by voiceParamsMutex_. NOT read on the audio thread — each setter rebuilds the VoiceEngine - // OFF-thread via rebuildVoiceEngine (a LIGHT rebuild around the already-decoded keymap; no - // bridge read, no WAV re-decode) published through the same tail-preserving drain-slot swap, - // so changing polyphony / mode / the retrigger toggle never cuts a ringing tail. + // Voice-system parameters (per-instance), guarded by voiceParamsMutex_, not read on the + // audio thread — each setter rebuilds via rebuildVoiceEngine (already-decoded keymap, no + // bridge/WAV re-read) through the same drain-slot swap, so a change never cuts a tail. int voiceCount(); void setVoiceCount(int count); // clamped to kMinVoiceCount..kMaxVoiceCount VoiceMode voiceMode(); @@ -243,251 +174,160 @@ public: MonoTrigger monoTrigger(); void setMonoTrigger(MonoTrigger trigger); - // --- FB1 post-mixer master gain (per-instance, persisted in component state v8) --------- - // LINEAR gain in [0, masterGainMaxLinear()] (0.0 = -inf/true silence, 1.0 = unity, cap = - // +24 dB; the pure master_gain module owns the dB knob taper). Held in an atomic so the - // audio thread applies it with ONE relaxed load per block as a post-sum multiply over the - // rendered output (engine + drain + preview) — no lock, no rebuild, no per-voice cost. - // Written by the editor's Gain knob (UI thread) and setState; read by getState + process(). + // Post-mixer master gain, linear in [0, masterGainMaxLinear()] (0 = true silence, 1 = + // unity, cap +24 dB). Atomic — the audio thread applies it as a per-block post-sum + // multiply, no lock, no rebuild. double masterGainLinear() const { return static_cast(masterGain_.load(std::memory_order_relaxed)); } void setMasterGainLinear(double linear); // clamped to [0, masterGainMaxLinear()] - // Fire a one-shot PREVIEW note-on / note-off through the live instrument's MAIN - // VoiceEngine — the SAME noteOn/noteOff calls host MIDI takes, so a preview is a REAL - // voice: it counts against the voice count, can steal / be stolen, and respects - // Poly/Mono + Retrigger/Legato (deliberate reversal of the retired PreviewCard's - // isolation — preview must obey voicing). The editor posts the loaded capture's / - // selected zone's ROOT note (plays at unity); previewNoteOn plays it at the current - // previewVelocity() (the velocity curve applies); previewNoteOff releases it (Gate) — - // Trigger zones ignore note-off and play through. OFF the audio thread (the editor's - // preview-trigger button, UI thread); the request is handed to process() via a - // lock-free single-slot mailbox drained at block start — no allocation, no lock on the - // audio thread. A momentary button (down = on, up = off) reads as a natural key press. - // This is PLAYBACK ONLY: it never captures, never inserts a timeline item. + // Fires a one-shot preview note-on/off through the live VoiceEngine — the same + // noteOn/noteOff host MIDI uses, so a preview is a real voice (counts against voice + // count, can steal/be stolen, respects Poly/Mono + Retrigger/Legato). Off the audio + // thread; handed to process() via a lock-free single-slot mailbox drained at block + // start. Never captures, never inserts a timeline item. void previewNoteOn(int note); void previewNoteOff(int note); - // The instance-owned sample refs (pS self-contained playback): a snapshot copy for the - // editor (waveform/loop-intrinsic fallback when the bank blob is not readable). UI - // thread; guarded by refsMutex_. + // Snapshot copy of the instance-owned sample refs, for the editor's waveform/loop + // fallback when the bank blob is unreadable. Guarded by refsMutex_. SampleRefs sampleRefs(); private: - // Phase S drain retirement (FA1-review Major #2): if process() has published that the - // CURRENT drain instrument is fully idle (every engine voice silent), - // move it out of the drain slot into the graveyard and prune — so an edited-away snapshot - // stops costing resident memory as soon as its tails die, instead of squatting in the slot - // until the NEXT reload. Off the audio thread only (takes reloadMutex_); driven from - // pollBankSync's UI-timer tick (the same cadence that drives reloads — an idle drain with - // no editor open simply waits for the next reload/deactivate, exactly the pre-fix bound). - // Safe against a racing process(): idleness is monotone (the drain receives no note-ons) - // and the published value names the drain's OWN installedAt, so a stale publication about - // an OLDER drain can never retire a newer one; the graveyard prune's monotone-generation - // proof (see below) covers the free. + // If process() published that the drain instrument is fully idle, move it into the + // graveyard and prune — so an edited-away snapshot stops costing memory as soon as its + // tails die. Off the audio thread only (driven by pollBankSync); safe against a racing + // process() because idleness is monotone and the publication names the drain's own + // installedAt (a stale value can never retire a newer occupant). void retireIdleDrain(); - // Phase S voice-param LIGHT rebuild (voice-review Major #3): rebuild the engine - // around a COPY of the LIVE instrument's already-decoded Keymap — no bridge read, no - // filesystem, no WAV re-decode — and publish through the same tail-preserving drain-slot - // swap as a full reload. A polyphony/mode/trigger change touches no audio data, so the - // full reloadInstrument (which re-decodes every zone WAV from disk on the UI thread) was - // pure waste — a visible UI stall on a many-zone instrument. Copying the keymap is safe: - // it is immutable after construction and, under reloadMutex_, the live instrument can - // neither be swapped nor freed while we read it. When nothing is loaded this is a no-op — - // the new params bake into the next real reload. Off the audio thread only. + // Light voice-param rebuild: rebuilds the engine around a copy of the live instrument's + // already-decoded Keymap (no bridge/disk) and publishes through the same drain-slot + // swap as a full reload. No-op when nothing is loaded. Off the audio thread only. void rebuildVoiceEngine(); - // The pre-v10 LEGACY LIFT gate (#A): true when a lift attempt this tick could make - // progress. Latches legacyLiftConcluded_ on a Stale proof (see the member below); the - // pure decision itself is sample_map's legacyLiftDecision. Off the audio thread only - // (bridge read + bank parse). + // Pre-v10 legacy-lift gate: true when a lift attempt this tick could make progress + // (see legacyLiftConcluded_). Off the audio thread only (bridge read + bank parse). bool legacyLiftShouldRun(); - // Publish `built` (null = install silence) into live_: prune the graveyard by the last - // process()-published generation, swap `built` into live_, displace the previous live into - // the drain slot, and park the drain-evicted instrument in the graveyard. REQUIRES - // reloadMutex_ held — factored out so reloadInstrument and rebuildVoiceEngine share the ONE - // safety-critical swap dance (see the handoff proof below). + // Publishes `built` (null = install silence) into live_: prunes the graveyard by the + // last process()-published generation, swaps `built` into live_, displaces the previous + // live into the drain slot, and parks the evicted drain instrument in the graveyard. + // Requires reloadMutex_ held — shared by reloadInstrument and rebuildVoiceEngine. void publishBuiltLocked(std::unique_ptr built); - // pS-usage: publish this instance's held captures to its per-instance ext-state key - // ("rsusage_") so the extension's prune counts them as referenced — a - // capture a live instance holds can never be pruned. Called at the end of every - // reloadInstrument (the ONE choke point every play-set change funnels through: - // selection change, zone edits, assignment consume, bank refresh, setState load), so - // publishing is EAGER and needs no timer — a closed-editor instance's record is - // already in ext-state from its last change/load. OFF THE AUDIO THREAD only (bridge - // calls). Mints instanceGuid_ on first need; RE-mints when planUsagePublish detects - // this state was cloned onto another track (FX copy / track duplication). Idempotent - // on an unchanged play-set (skipWrite). `refs`/`ids` are reloadInstrument's own - // snapshot — the refs table and the id set the instance currently plays. + // Publishes this instance's held captures to its per-instance ext-state key + // ("rsusage_") so the extension's prune can never reclaim them. Called at + // the tail of every reloadInstrument, off the audio thread. Mints instanceGuid_ on + // first need; re-mints on a detected clone (FX copy / track duplication). void publishUsage(const SampleRefs& refs, const std::vector& ids); ReaperBridge bridge_; - // --- The audio-thread handoff (S4 real-time discipline, FA1 drain slot) -- - // process() atomically loads `live_` AND `draining_` at block start and marshals/renders - // against them — two atomic acquires, no lock, no free on the audio thread. + // --- The audio-thread handoff (drain slot) --- + // process() atomically loads live_ + draining_ at block start (two acquires, no lock). + // reloadInstrument() (off-thread, serialized by reloadMutex_) swaps a new build into + // live_; the displaced instrument moves to draining_, where process() keeps rendering + // its already-sounding voices (and routes note-offs to it) so a reload never cuts a + // ringing note — new note-ons go only to live_. The instrument evicted from draining_ + // (two reloads old) parks in graveyard_ for reclaim. // - // reloadInstrument() (off-thread, serialized by reloadMutex_) builds a new - // LoadedInstrument and atomically swaps it into `live_`. The DISPLACED instrument is - // NOT freed and NOT silenced: it moves into `draining_`, where process() keeps - // rendering its already-sounding voices (and routes note-offs to it) so a reload — - // a curve/param edit, a bank-generation refresh, an applied assignment — never cuts a - // ringing note (FA1, bug 3b). New note-ons go ONLY to the live instrument, so the next - // trigger plays the new state. The instrument evicted FROM the drain slot (two reloads - // old) is parked in `graveyard_` for reclaim — a rapid second reload hard-cuts only the - // oldest edit's tails (bounded compromise, documented). + // Reclaim: process() publishes the minimum installedAt it holds via processGeneration_ + // (one relaxed store); the reload path frees graveyard entries older than that. Safe + // because both slots are monotone in installedAt, so the published minimum is monotone + // and an entry only reaches the graveyard after leaving both slots under reloadMutex_ — + // an entry below the published minimum can never be loaded again. // - // Bounded reclaim: process() publishes the MINIMUM installedAt over the (non-null) - // pointers it holds this block via processGeneration_ — a single atomic store, RT-safe. - // The reload path frees graveyard entries whose installedAt < seen (the last published - // value). - // - // Safety argument: both slots are monotone in installedAt over time (live_ receives - // successively newer builds; draining_ receives successively newer displaced lives), so - // the published minimum is monotone across blocks, and any future process() load yields - // installedAt >= seen. An entry only reaches the graveyard by leaving BOTH slots - // (single-writer under reloadMutex_), so a graveyard entry with installedAt < seen can - // never again be loaded and is not currently held — freeing it is safe. process() - // publishes BEFORE rendering, so the pointers it renders with are covered by the value - // the pruner reads (a stale lower read is merely conservative). - // - // The graveyard's upper bound is the number of reloads since process last ran - // (typically 0–1 in normal use). Remaining entries drain at setActive(false) / - // terminate(), when the host guarantees process is stopped. + // Graveyard upper bound: reloads since process last ran (typically 0-1). Remaining + // entries drain at setActive(false) / terminate(), when process is guaranteed stopped. std::atomic live_{nullptr}; std::atomic draining_{nullptr}; // displaced instrument still rendering its tails std::atomic reloadGeneration_{0}; // incremented by each reload (off-thread, under reloadMutex_; read atomically by process) std::atomic processGeneration_{0}; // min installedAt held by process (written on audio thread, read off-thread) - // Phase S: the installedAt of the drain instrument process() last observed FULLY IDLE - // (every engine voice silent; 0 = none / the current drain still sounds). Written relaxed on the audio thread each - // block; read by retireIdleDrain() off-thread. Naming the generation (not a bool) closes - // the swap race: a publication about an old drain can never retire its successor. + // The installedAt of the drain instrument process() last observed fully idle (0 = none / + // still sounds). Written relaxed on the audio thread each block; read by retireIdleDrain() + // off-thread. Naming the generation (not a bool) closes the swap race: a publication about + // an old drain can never retire its successor. std::atomic drainIdleGeneration_{0}; std::vector> graveyard_; // drained on reclaim + setActive(false) + terminate std::mutex reloadMutex_; // serializes off-thread reloads + graveyard access - // The single-capture selection id (S10: the ONE picked capture; "" = no pick -> silence). - // Off-thread only; a small mutex guards the string against a getState/editor race. NOT - // read on the audio thread. + // The single-capture selection id ("" = no pick -> silence). Off-thread only, not read + // on the audio thread. std::mutex selectionMutex_; std::string selectedSampleId_; - // The performance map (Tier 1: the instrument's owned zoned keymap). Off-thread only; - // guarded against a getState/editor race. NOT read on the audio thread — reloadInstrument - // bakes it into the LoadedInstrument's Keymap under the reload lock. + // The performance map (zoned keymap). Off-thread only; reloadInstrument bakes it into + // the Keymap under the reload lock, never read directly on the audio thread. std::mutex performanceMutex_; PerformanceMap performanceMap_; - // The instance-OWNED sample refs (pS self-contained playback): the path + intrinsics - // per referenced bank sample that setState restores, reloadInstrument resolves/decodes - // from, and getState persists (v10). Refreshed opportunistically from the bank blob - // when it is readable; NEVER a bank dependency for playback. Off-thread only (UI + - // load/save + reload); guarded against a getState/reload race. NOT read on the audio - // thread. + // Instance-owned sample refs: path + intrinsics per referenced sample. Refreshed + // opportunistically from the bank blob when readable; never a bank dependency for + // playback. Off-thread only. std::mutex refsMutex_; SampleRefs sampleRefs_; - // pS-usage publish identity + lifetime nonce (see publishUsage). instanceGuid_ is - // the persisted per-instance identity (ComponentState v11; empty until first - // publish); usageNonce_ is THIS incarnation's per-LIFETIME owner nonce, carried - // INSIDE the published wire (UsageRecord.ownerNonce) — planUsagePublish's exact - // ownership discriminator between "my own write" (clean replace) and "a foreign - // writer" (union / re-mint). NEVER persisted: a persisted nonce would clone with - // the state on FX copy, and two same-track copies converging on byte-identical - // wires is exactly the ambiguity the nonce exists to break (a wire-equality - // discriminator let sibling A clean-replace over sibling B's still-held paths — - // the delete direction). Minted lazily on first publish; cleared on setState (a - // restored blob is a new lifetime). Guarded by usageMutex_ (publish runs under - // reloadMutex_ but getState/setState do not). + // Usage-publish identity (see publishUsage). instanceGuid_ is the persisted per-instance + // identity; usageNonce_ is this incarnation's per-lifetime owner nonce (never persisted — + // a persisted nonce would clone with the state on FX copy, letting a sibling clean- + // replace over another's held paths). Minted lazily; cleared on setState. std::mutex usageMutex_; std::string instanceGuid_; std::string usageNonce_; - // The per-instance channel mode (S7). Off-thread only (UI + getState + reloadInstrument); - // guarded against a getState/editor race. Default Mono preserves pre-S7 behavior. NOT read - // on the audio thread — process renders against the host's negotiated output channel count. - // channelModeExplicit_ (GA, persisted v9): false = the mode is an un-touched default that - // reloadInstrument may auto-default from the loaded capture's channel count; true = the user - // deliberately toggled the mode (setChannelMode latches it) and it is never fought. + // Per-instance channel mode, default Mono; not read on the audio thread (process + // renders against the host's negotiated channel count). channelModeExplicit_: false = + // reloadInstrument may auto-default the mode from the loaded capture; true = the user + // deliberately toggled it (never fought thereafter). std::mutex channelModeMutex_; ChannelMode channelMode_ = ChannelMode::Mono; bool channelModeExplicit_ = false; - // The last assignment-request generation this instance CONSUMED (S8 reader). Persisted in - // component state (v5) so a re-open does not re-apply a request the user already got and - // then changed away from. Written by pollBankSync (UI/timer thread) and getState; read by - // pollBankSync + getState; seeded by setState. Guarded against a getState/poll race. NEVER - // read on the audio thread. Default 0 -> a genuinely new first assign (gen >= 1) applies. + // The last assignment-request generation consumed, persisted so a re-open does not + // re-apply a stale request. Default 0 -> a genuinely new first assign (gen >= 1) applies. std::mutex assignMarkerMutex_; std::int64_t lastConsumedAssignGeneration_ = 0; - // The bank generation this instance last SAW (S9 reader). UI/timer-thread only (pollBankSync - // is the sole reader/writer) — no mutex needed, and it is NOT persisted. Initialized to a - // -1 SENTINEL (no real generation can be negative — parseBankGeneration yields >= 0) so the - // FIRST poll after an editor open BASELINES the seen value without a redundant reload - // (setState already loaded the instrument from the OWNED refs); a subsequent generation - // CHANGE then drives the reload. Since pS there is NO reopen-heal here: playback never - // depends on this poll — a v10 blob plays from its own refs at setState time. Besides a - // generation change, pollBankSync reloads only for an APPLIED S8 assignment and for the - // pre-v10 LEGACY LIFT. NOT read on the audio thread. + // The bank generation this instance last saw. UI/timer-thread only (pollBankSync's sole + // reader/writer), not persisted. -1 sentinel baselines the first poll without a + // redundant reload; a later generation change then drives the reload. std::int64_t lastSeenBankGeneration_ = -1; - // The pre-v10 LEGACY LIFT's terminating latch (#A): set once legacyLiftShouldRun proves - // the referenced ids STALE against a readable bank blob (LegacyLiftDecision::Stale) — - // there is nothing to lift, so the lift stops re-firing (the steady state is one relaxed - // load per tick, no bank read). Reset by setState (a new blob = new facts). NOT consulted - // by the genChanged/applied reload paths, so a later bank change that re-introduces an id - // (e.g. an extension-side undo) still refreshes the refs — the latch only gates the lift. - // Atomic: written on the UI-timer thread (pollBankSync) and the host load thread (setState). + // Legacy-lift terminating latch: set once legacyLiftShouldRun proves the referenced ids + // stale against a readable bank blob, so the lift stops re-firing every tick. Reset by + // setState (a new blob = new facts). std::atomic legacyLiftConcluded_{false}; - // S-VIEW-4 preview-trigger velocity (MIDI 1..127). Persisted in component state (v6) so the - // user's chosen strike velocity survives a project save/reload. Since Wave 2 the Sample-view - // velocity knob writes it on the UI thread, so it is guarded by previewMutex_; setState and - // getState (load/save thread) share the same guard. Default kPreviewVelocityDefault (64). NOT - // read on the audio thread. + // Preview-trigger velocity (MIDI 1..127, persisted). Default kPreviewVelocityDefault + // (64). Not read on the audio thread. std::mutex previewMutex_; std::uint8_t previewVelocity_ = kPreviewVelocityDefault; - // Phase S voice-system parameters (per-instance, persisted in component state v7). Off-thread - // only (UI voice deck + getState/setState + reloadInstrument); guarded against a getState/editor - // race. Defaults {16, Poly, Retrigger} reproduce pre-Phase-S behavior. NOT read on the audio - // thread — reloadInstrument bakes them into the LoadedInstrument's engine off-thread. + // Voice-system parameters (per-instance, persisted). Defaults {16, Poly, Retrigger}. + // Not read on the audio thread — reloadInstrument bakes them into the engine off-thread. std::mutex voiceParamsMutex_; int voiceCount_ = kDefaultVoiceCount; VoiceMode voiceMode_ = VoiceMode::Poly; MonoTrigger monoTrigger_ = MonoTrigger::Retrigger; - // FB1 post-mixer master gain (LINEAR; persisted in component state v8). A lock-free - // atomic — the target the UI thread writes; the audio thread ramps gainCurrent_ toward - // it per-sample each block (linear interpolation, ~20 ms wall-clock at every host rate) - // so sudden knob moves produce no zipper noise and the true-zero bottom causes no click. + // Post-mixer master gain (linear, persisted). Lock-free atomic target; the audio thread + // ramps gainCurrent_ toward it per-sample (~20 ms wall-clock at every host rate) so + // knob moves produce no zipper noise. std::atomic masterGain_{1.0f}; - // The audio-thread running gain value: tracks masterGain_ across blocks, stepping at - // most gainRampStep_ per sample toward the target. Starts at unity (pre-FB1 default). - // Written and read exclusively on the audio thread — no atomics needed. + // Audio-thread running gain value, stepping at most gainRampStep_ per sample toward the + // target. Written/read exclusively on the audio thread — no atomics needed. float gainCurrent_ = 1.0f; - // T3-01: the per-sample ramp step, derived from kGainRampSeconds (20 ms wall-clock) - // against the live host rate in setupProcessing — never a baked-in rate. The default is - // the 48 kHz value so behavior before the first setupProcessing is unchanged. Written in - // setupProcessing (host-serialized against process), read on the audio thread. + // Per-sample ramp step derived from kGainRampSeconds against the live host rate in + // setupProcessing — never a baked-in rate. Default is the 48 kHz value. float gainRampStep_ = 1.0f / 960.0f; - // --- S-VIEW-4 preview-trigger mailbox (off-thread -> audio thread, lock-free) --------- - // The editor's preview-trigger button posts a note-on/off request from the UI thread; process() - // drains it at block start and drives the live instrument's MAIN VoiceEngine — the same - // noteOn/noteOff host MIDI takes, so the preview obeys voicing. ONE slot per direction, each a packed - // request whose high bits are a monotonically-incrementing sequence so process() detects a NEW - // request by comparing against the last sequence it consumed (never re-firing a stale one). The - // low 8 bits carry the note (on) / note (off); the on request also carries the velocity in the - // next 8 bits, latched at post time so the audio thread reads no shared velocity field. A single - // relaxed atomic load per block on the audio thread — RT-safe (no alloc, no lock). - // packed = (seq << 16) | (velocity << 8) | note [note-on] - // packed = (seq << 16) | note [note-off] + // --- Preview-trigger mailbox (off-thread -> audio thread, lock-free) ----------------- + // One slot per direction, packed as (seq << 16) | (velocity << 8) | note [on] or + // (seq << 16) | note [off]. process() detects a new request by comparing the packed + // sequence against the last one consumed — a single relaxed atomic load per block, + // RT-safe (no alloc, no lock). std::atomic previewOnRequest_{0}; // 0 = no request posted yet std::atomic previewOffRequest_{0}; std::uint16_t previewOnSeq_ = 0; // UI-thread post counter (never 0 after first post) @@ -495,22 +335,17 @@ private: std::uint16_t previewOnConsumed_ = 0; // audio-thread: last on-seq fired std::uint16_t previewOffConsumed_ = 0; // audio-thread: last off-seq fired - // Latched from setupProcessing so setActive/reload can size against it. Read - // off-thread only. 0.0 is explicitly invalid — setupProcessing sets the real host rate - // before any audio, and reloadInstrument guards on it before use. + // Latched from setupProcessing; 0.0 is explicitly invalid (reloadInstrument guards on it). double sampleRate_ = 0.0; Steinberg::int32 maxBlockSize_ = 4096; - // --- S6 embedded TCP/MCP UI --------------------------------------------- - // The embed shell (IReaperUIEmbedInterface), created lazily on the first queryInterface - // and owned here for the processor's lifetime. REAPER borrows AddRef'd references from - // queryInterface; the shell's refcount is a no-op because THIS unique_ptr governs its - // destruction (the processor always outlives the borrowed references). + // The embed shell, created lazily on the first queryInterface and owned here for the + // processor's lifetime; REAPER's borrowed AddRef'd references are outlived by this + // unique_ptr, so its own refcount is a no-op. std::unique_ptr embed_; - // The per-block mono peak (0..1+) the audio thread stores relaxed; the embed strip's - // level indicator reads it via embedActivityLevel(). Advisory only — a plain atomic, - // no ordering coupling, never guarded by a lock the audio thread touches. + // Per-block mono peak the audio thread stores relaxed; embedActivityLevel() reads it + // for the embed strip's level indicator. Advisory only. std::atomic embedPeak_{0.f}; }; diff --git a/src/shell/instrument/reasampler_vst.h b/src/shell/instrument/reasampler_vst.h index 3993279..6bdd0af 100644 --- a/src/shell/instrument/reasampler_vst.h +++ b/src/shell/instrument/reasampler_vst.h @@ -1,21 +1,8 @@ -// reasampler_vst.h — shared identity constants for the ReaSampler VST3 instrument -// (Phase S). One place for the plugin's class UID, name, vendor, and version so the -// processor, factory, and editor agree. -// -// A class UID is FOREVER-STABLE once shipped: a REAPER project that instantiates this -// instrument records the UID, so changing it orphans every saved instance. Minted once; -// do not regenerate. -// -// CHANNEL ISOLATION (S18, beta-in-isolation — the instrument-side companion to V4). Just -// as V4 gave the extension a per-channel ext-state namespace / command-id family / dock -// ident, S18 gives the VST3 instrument a per-channel PLUGIN IDENTITY: its class UID, its -// on-disk filename, and its display name all fork by the ONE channel bit -// (REASAMPLER_CHANNEL_IS_BETA, from version_generated.h). ONE class per binary — the bit -// selects which UID compiles into the single DEF_CLASS2, so a beta build carries only the -// beta identity and can never present the stable one (mirrors V4's fully-isolated-binary -// philosophy). The two UIDs below are BOTH frozen forever; the filename + display name -// derive from app_version's vstOutputName()/vstPluginName() (this header owns only the -// binary UID identity — the string identity lives in the pure module). +// reasampler_vst.h — shared identity constants for the ReaSampler VST3 instrument: the +// plugin's class UID, vendor name/URL/email, so the processor, factory, and editor agree. +// A class UID is FOREVER-STABLE once shipped (see this directory's CLAUDE.md) — minted +// once, never regenerated. Filename + display name are channel-derived from app_version's +// string accessors; this header owns only the binary UID identity. #pragma once @@ -25,23 +12,16 @@ namespace reasampler::vst { -// Vendor identity (S-NAME-1, SETTLED 2026-07-26). Shared across channels — V4 kept the -// lane-name prefix shared, so shared-where-V4-shares is the default (the channel is carried -// by the UID + filename + display fork, not the vendor block). +// Vendor identity, shared across channels — the channel is carried by the UID + filename + +// display fork, not the vendor block. inline constexpr const char* kVendorName = "ReaSampler"; inline constexpr const char* kVendorUrl = "https://github.com/daniel-c-harvey/reasampler"; inline constexpr const char* kVendorEmail = "mailto:the.real.daniel.harvey@gmail.com"; -// ----------------------------------------------------------------------------------------- // The two FOREVER-FROZEN VST3 class UIDs — one per channel — live in reasampler_uid.h // (SDK-free, so the extension's pure instrument_drop can render the .vstpreset class-ID -// string from the SAME constants without pulling the VST3 SDK). A saved REAPER project -// records the UID of the instance it instantiated and rebinds by it on reopen, so each is -// a permanent commitment. The channel bit selects which one this binary's factory registers -// — one class per binary, never both. The UID selection is the ONLY channel #ifdef in the -// VST shell (an INLINE_UID needs literal brace-init tokens, so it cannot route through -// app_version's runtime string accessors — reasampler_uid.h owns the binary UID fork, -// app_version owns the string fork). +// string from the same constants without pulling the VST3 SDK). The channel bit selects +// which one this binary's factory registers — one class per binary, never both. // The runtime FUID for the class this binary registers — the channel-selected UID. static const Steinberg::FUID kReaSamplerProcessorUID(REASAMPLER_ACTIVE_UID_1, diff --git a/src/shell/instrument/vst_entry.cpp b/src/shell/instrument/vst_entry.cpp index 0ca6ed3..7cfe745 100644 --- a/src/shell/instrument/vst_entry.cpp +++ b/src/shell/instrument/vst_entry.cpp @@ -1,21 +1,9 @@ -// vst_entry.cpp — the VST3 module class factory (Phase S1). Enumerates the one class -// this module offers (the ReaSampler instrument) via the SDK's factory macros. The -// Windows module exports — GetPluginFactory (here, via BEGIN_FACTORY) and -// InitDll/ExitDll (from the SDK's dllmain.cpp) — are how REAPER discovers and loads a -// VST3. -// -// VERIFIED (corrects §1a's "experienced estimate" flags on export names + macros, -// against vendor/vst3sdk/public.sdk/source/main/): -// * Windows exports: InitDll / ExitDll (SMTG_EXPORT_SYMBOL, in dllmain.cpp) + -// GetPluginFactory (SMTG_EXPORT_SYMBOL IPluginFactory* PLUGIN_API, emitted by the -// BEGIN_FACTORY macro). The plug-in must provide InitModule/DeinitModule — supplied -// here by linking moduleinit.cpp (the SDK's default one-time init/term). -// * Factory macros: BEGIN_FACTORY(vendor,url,email,flags) / DEF_CLASS2(...) / -// END_FACTORY — exact spellings from pluginfactory.h. -// * Instrument subcategory string: "Instrument|Synth|Sampler" -// (PlugType::kInstrumentSynthSampler, ivstaudioprocessor.h). -// * classFlags = 0 for a SingleComponentEffect (non-distributable), matching the -// AGain example. +// vst_entry.cpp — the VST3 module class factory. Enumerates the one class this module +// offers via the SDK's factory macros. Windows module exports — GetPluginFactory (here, +// via BEGIN_FACTORY) and InitDll/ExitDll (SDK's dllmain.cpp) — are how REAPER discovers +// and loads a VST3. Verified against vendor/vst3sdk/public.sdk/source/main/: the plug-in +// must supply InitModule/DeinitModule (linked here via moduleinit.cpp). classFlags = 0 for +// a SingleComponentEffect (non-distributable), matching the AGain example. #include "public.sdk/source/main/pluginfactory.h" @@ -26,23 +14,16 @@ #include "shell/instrument/reasampler_processor.h" #include "shell/instrument/reasampler_vst.h" // channel-selected class UID (REASAMPLER_ACTIVE_UID_*) -// CHANNEL PAIRING INVARIANT (S18). The instrument's PLUGIN identity forks by the ONE channel -// bit (REASAMPLER_CHANNEL_IS_BETA — the class UID selected in reasampler_vst.h, the filename -// + display name in app_version). Its DATA identity forks by the SAME bit, one layer down: -// ext_keys.h's kProjExtNamespace() delegates to app_version::extStateNamespace(), so a beta -// binary reads "reasampler_beta". Both derive from that one bit, so a beta VST can only ever -// talk to the beta extension. +// The instrument's plugin identity (UID + filename + display) and its data identity +// (ext_keys.h's kProjExtNamespace(), delegating to app_version::extStateNamespace()) both +// fork from the one REASAMPLER_CHANNEL_IS_BETA bit, so a beta VST can only ever talk to +// the beta extension. // -// The guard below pins the two forks together so a refactor cannot split them. It asserts -// that the CLASS UID this factory registers (REASAMPLER_ACTIVE_UID_1, selected by the #if in -// reasampler_vst.h) is the UID that matches THIS binary's channel bit. If someone edited that -// #if to pick the wrong branch — registering the stable UID in a beta build, or vice versa — -// the instrument's identity would diverge from the namespace ext_keys reads (a beta-named -// plugin presenting the stable UID, or reading the stable banks under a beta identity). That -// is exactly the silent split the invariant forbids, and it breaks the build here instead. -// (The namespace itself is a runtime accessor — .c_str() on a channel-selected string — so -// the couplable compile-time fact is the UID selection, not the namespace value; the -// app_version_tests pin the namespace string per channel.) +// The guard below pins the two forks together so a refactor cannot split them: it asserts +// the class UID this factory registers matches this binary's channel bit. If the #if in +// reasampler_vst.h picked the wrong branch, the instrument's identity would diverge from +// the namespace ext_keys reads (a beta-named plugin presenting the stable UID, or vice +// versa) — this breaks the build instead of shipping that silent split. #if REASAMPLER_CHANNEL_IS_BETA static_assert(REASAMPLER_ACTIVE_UID_1 == REASAMPLER_PROC_UID_BETA_1 && REASAMPLER_ACTIVE_UID_2 == REASAMPLER_PROC_UID_BETA_2 && @@ -62,13 +43,9 @@ static_assert(REASAMPLER_ACTIVE_UID_1 == REASAMPLER_PROC_UID_1 && BEGIN_FACTORY(reasampler::vst::kVendorName, reasampler::vst::kVendorUrl, reasampler::vst::kVendorEmail, Steinberg::PFactoryInfo::kNoFlags) -// The display name and version are channel-derived from app_version — sourced here, not -// as literals. DEF_CLASS2 expands inside GetPluginFactory() and PClassInfo2's constructor -// copies the char* into its own fixed buffer at that runtime call, so .c_str() on the -// accessors' static-storage strings is valid (no dangling — the refs outlive the copy). -// vstPluginName(): "ReaSampler 9000" / "ReaSampler 9000 beta" (live literals in -// app_version.cpp). appVersion(): the configured version string / that string plus -// "-beta" (the -beta render V4 already yields on beta). +// Display name + version are channel-derived from app_version, not literals. DEF_CLASS2 +// expands inside GetPluginFactory(); PClassInfo2's constructor copies the char* into its +// own buffer at that call, so .c_str() on the accessors' static-storage strings is valid. DEF_CLASS2(INLINE_UID(REASAMPLER_ACTIVE_UID_1, REASAMPLER_ACTIVE_UID_2, REASAMPLER_ACTIVE_UID_3, REASAMPLER_ACTIVE_UID_4), Steinberg::PClassInfo::kManyInstances, // cardinality diff --git a/src/shell/panel/draw_kit.cpp b/src/shell/panel/draw_kit.cpp index c734d01..4f7c9bd 100644 --- a/src/shell/panel/draw_kit.cpp +++ b/src/shell/panel/draw_kit.cpp @@ -1,17 +1,14 @@ // draw_kit — the LICE/SWELL shell half of the drawing kit. See draw_kit.h. -// -// Compiled into the reaper_reasampler MODULE. SHELL layer: it is the only kit file that -// touches LICE + SWELL. All colors come from the pure `theme` module; all geometry from -// the pure `component_geometry` module. DAW-verified, not unit-tested. +// SHELL: the only kit file that touches LICE + SWELL. Colors come from `theme`; +// geometry from `component_geometry`. DAW-verified, not unit-tested. #include "shell/panel/draw_kit.h" #include -#include "core/ui/bank_grid.h" // compressAmplitudeForDisplay — the shared dB display curve (pure) +#include "core/ui/bank_grid.h" // compressAmplitudeForDisplay — the shared dB display curve -// SWELL / LICE. On Windows use native Win32 (windows.h first); on mac/linux SWELL is -// provided by the host. Mirrors the panel TUs' (shell/panel/) include discipline. +// On Windows use native Win32 (windows.h first); on mac/linux SWELL is provided by the host. #ifdef _WIN32 #include #else @@ -23,7 +20,6 @@ namespace reasampler { -// Real-namespace-home using-declarations (Q-W6: the namespaces.h shim is retired). using audio::ChannelEnvelope; using audio::columnMinMax; using audio::MinMax; @@ -32,24 +28,18 @@ using ui::roleColor; using ui::roleColorState; using ui::spectralColor; -// --- KitColor <-> LICE boundary ---------------------------------------------- - -// The one place a pure KitColor becomes a LICE_pixel. Verified packing: LICE_RGBA(r,g,b,a) -// (lice.h:57). The theme owns the color; the shell owns the packing. Declared in draw_kit.h -// so shell translation units (bank_panel) can use it without duplicating the LICE_RGBA pack. +// The one place a pure KitColor becomes a LICE_pixel (LICE_RGBA(r,g,b,a), verified against +// lice.h). The theme owns the color; the shell owns the packing. LICE_pixel toLice(const KitColor& c) { return LICE_RGBA(c.r, c.g, c.b, c.a); } namespace { -// The draw alpha the LICE primitives take (0..1), from the KitColor's 8-bit alpha. Used so -// a disabled surface (alpha 0.4) composites at the right opacity — LICE_FillRect etc. take -// a float alpha argument separate from the pixel's own alpha byte. +// LICE_FillRect etc. take a float alpha (0..1) separate from the pixel's own alpha byte — +// this converts a KitColor's 8-bit alpha so a disabled surface composites at the right opacity. float drawAlpha(const KitColor& c) { return c.a / 255.0f; } -// --- Font set (owned by the kit) --------------------------------------------- - struct KitFonts { LICE_CachedFont title; LICE_CachedFont label; @@ -92,8 +82,8 @@ UINT alignFlag(Align a) { return DT_LEFT; } -// A 1px inner highlight on the top edge and shadow on the bottom edge — the vwnd trick -// that gives a flat fill dimension (§2.2). Lightens the top row, darkens the bottom row. +// A 1px inner highlight on the top edge and shadow on the bottom edge gives a flat fill +// dimension without a border. void innerEdges(LICE_IBitmap* bmp, const KitBox& b, float alpha) { if (b.width < 2 || b.height < 2) return; const LICE_pixel hi = LICE_RGBA(255, 255, 255, 255); @@ -111,9 +101,8 @@ void fillGradient(LICE_IBitmap* bmp, const KitBox& b, const KitColor& top, const KitColor& bottom) { if (b.empty()) return; const float a = drawAlpha(top); - // LICE_GradRect wants initial R/G/B/A (0..1) and per-axis deltas. Verified signature - // lice.h:466 — ir..ia are the top-left color; drdy..dady ramp DOWN the height so the - // bottom row reaches `bottom`. No horizontal ramp (drdx.. = 0). + // LICE_GradRect (lice.h) takes initial R/G/B/A plus per-axis deltas: ir..ia are the + // top-left color, drdy..dady ramp DOWN the height so the bottom row reaches `bottom`. const float ir = top.r / 255.0f, ig = top.g / 255.0f, ib = top.b / 255.0f; const float dr = (bottom.r - top.r) / 255.0f; const float dg = (bottom.g - top.g) / 255.0f; @@ -145,12 +134,8 @@ RECT toRect(const KitBox& b) { } // namespace -// --- Font lifecycle ---------------------------------------------------------- - void kitFontsInit() { if (g_fonts.ready) return; // idempotent - // §3.1 type scale: title ~15px semibold, label ~12px, value-mono ~12px tabular, - // micro ~10px. Segoe UI (universal on the Windows target); Consolas for numerics. loadFont(g_fonts.title, 15, FW_SEMIBOLD, "Segoe UI"); loadFont(g_fonts.label, 12, FW_NORMAL, "Segoe UI"); loadFont(g_fonts.valueMono, 12, FW_NORMAL, "Consolas"); @@ -160,11 +145,8 @@ void kitFontsInit() { void kitFontsShutdown() { if (!g_fonts.ready) return; // idempotent - // LICE_CachedFont's destructor frees its OWNS_HFONT HFONT. Re-assigning an empty font - // via SetFromHFont(nullptr) would leak nothing but also do nothing useful; instead we - // mark not-ready and let the fonts release their HFONTs when g_fonts is reset. Because - // g_fonts is a static instance (not re-created), free the HFONTs explicitly by handing - // each a null font, which OWNS semantics clean up the prior HFONT (lice_text.h:41). + // g_fonts is a static instance, never re-created, so free the HFONTs explicitly: + // handing each a null font with OWNS_HFONT cleans up the prior HFONT (lice_text.h). g_fonts.title.SetFromHFont(nullptr, LICE_FONT_FLAG_OWNS_HFONT); g_fonts.label.SetFromHFont(nullptr, LICE_FONT_FLAG_OWNS_HFONT); g_fonts.valueMono.SetFromHFont(nullptr, LICE_FONT_FLAG_OWNS_HFONT); @@ -172,8 +154,6 @@ void kitFontsShutdown() { g_fonts.ready = false; } -// --- Text -------------------------------------------------------------------- - void text(LICE_IBitmap* bmp, const KitBox& box, const char* str, Font font, const KitColor& color, Align align) { if (!bmp || !str || box.empty()) return; @@ -191,8 +171,6 @@ void text(LICE_IBitmap* bmp, const KitBox& box, const char* str, text(bmp, box, str, font, roleColor(role), align); } -// --- Surfaces + components ---------------------------------------------------- - void fillSurface(LICE_IBitmap* bmp, const KitBox& box, Role role, InteractionState state) { if (!bmp || box.empty()) return; const KitColor base = roleColorState(role, state); @@ -211,9 +189,8 @@ void drawButton(LICE_IBitmap* bmp, const KitButtonBox& button, const char* label KitColor top, bottom; gradientPair(base, top, bottom); - // Rounded surface: fill the interior gradient, then an AA rounded border. Corner - // radius scales gently with height, clamped so tiny buttons stay legible. fillGradient(bmp, b, top, bottom); + // Corner radius scales with height, clamped so tiny buttons stay legible. const int radius = b.height >= 20 ? 5 : (b.height >= 12 ? 3 : 2); const KitColor borderCol = (state == InteractionState::Active || state == InteractionState::Focus) @@ -224,9 +201,7 @@ void drawButton(LICE_IBitmap* bmp, const KitButtonBox& button, const char* label radius, toLice(borderCol), drawAlpha(borderCol), 0, true); if (label && *label) { - // Active fill is the accent — draw its label in the base bg for contrast; else - // text/primary (disabled dims via the state on the surface, label stays primary - // but the whole control reads recessed). + // Active fill is the accent — label goes in bg/base for contrast; else text/primary. const Role textRole = (state == InteractionState::Active) ? Role::BgBase : Role::TextPrimary; @@ -237,10 +212,8 @@ void drawButton(LICE_IBitmap* bmp, const KitButtonBox& button, const char* label void drawSlider(LICE_IBitmap* bmp, const SliderGeometry& geom, InteractionState state) { if (!bmp || geom.track.empty()) return; - // Track groove: the cell surface, recessed (pressed-ish) so it reads as a channel. fillSurface(bmp, geom.track, Role::BgCell, InteractionState::Pressed); - // Filled portion up to the handle: the accent (hover/dragging brighten it). if (!geom.filled.empty()) { const InteractionState fillState = (state == InteractionState::Hover || state == InteractionState::Dragging) @@ -251,7 +224,6 @@ void drawSlider(LICE_IBitmap* bmp, const SliderGeometry& geom, InteractionState fillGradient(bmp, geom.filled, top, bottom); } - // Handle: a raised knob honoring state. if (!geom.handle.empty()) { const KitButtonBox knob{geom.handle}; drawButton(bmp, knob, nullptr, state, /*warn=*/false); @@ -263,18 +235,16 @@ void drawListRow(LICE_IBitmap* bmp, const ListRowBox& row, const char* label, const KitBox& b = row.box; if (!bmp || b.empty()) return; - // Row surface: bg/cell transformed by state (hover lightens, active = accent). fillSurface(bmp, b, Role::BgCell, state); - // Focus ring: a 1px text/primary rectangle, distinct from the accent selection fill. + // Focus ring is text/primary, distinct from the accent selection fill. if (state == InteractionState::Focus) { const KitColor ring = roleColor(Role::TextPrimary); LICE_DrawRect(bmp, b.x, b.y, b.width - 1, b.height - 1, toLice(ring), drawAlpha(ring), 0); } - // Label in the width after the reserved thumbnail inset. Active rows draw the label in - // bg/base for contrast against the accent fill; else text/primary. + // Active rows draw the label in bg/base for contrast against the accent fill. if (label && *label) { const int inset = thumbWidth > 0 ? thumbWidth + 6 : 6; KitBox labelBox{b.x + inset, b.y, b.width - inset - 6, b.height}; @@ -314,13 +284,7 @@ void drawWaveform(LICE_IBitmap* bmp, const KitBox& box, const Envelope& env) { if (bins.empty() || innerW <= 0) continue; - // Render one filled vertical span per pixel column. peaks::columnMinMax merges - // all bins that project to column `col` under the exact same partition as - // computeEnvelope used to build the envelope, so every pixel column is covered - // with no gaps regardless of the bins-to-pixels ratio. With one bin per column - // (kWaveformOversample == 1) each span covers the true min/max of exactly the - // frames that fall in that column. Same dB display compression everywhere - // (bank_grid, pure). + // One filled span per pixel column (see draw_kit.h — gap-free via columnMinMax). for (int col = 0; col < innerW; ++col) { const MinMax mm = columnMinMax(bins, innerW, col); const int x = box.x + 2 + col; diff --git a/src/shell/panel/draw_kit.h b/src/shell/panel/draw_kit.h index 311d308..e747276 100644 --- a/src/shell/panel/draw_kit.h +++ b/src/shell/panel/draw_kit.h @@ -1,37 +1,29 @@ #pragma once -// draw_kit — the LICE-facing SHELL half of the shared drawing kit (Phase L, L1). This is -// the ONE source of drawing for the whole system: every surface (bank_panel now; the VST -// editor + embed strip at L3) fills, buttons, rows, sliders, waveforms, and — above all — -// draws TEXT through this kit, so a control looks identical everywhere because it is the -// same kit function. It replaces the flat LICE_FillRect blocks and raw-GDI DrawTextA with -// gradient/AA surfaces (the vwnd micro-gradient + inner highlight/shadow trick) and cached -// anti-aliased text (LICE_CachedFont), honoring the interaction-state model. +// draw_kit — the LICE-facing SHELL half of the shared drawing kit: the ONE source of +// drawing for the whole system (bank_panel, the VST editor, and the embed strip all fill, +// button, row, slider, waveform, and draw TEXT through this same kit, so a control looks +// identical everywhere). // -// PURE/SHELL SPLIT (CLAUDE.md §load-bearing): this file is SHELL — it touches LICE and -// SWELL (HFONT). All palette decisions come from the pure `theme` module (role -> KitColor); -// all layout/hit-test from the pure `component_geometry` / mode_switch / etc. modules. This -// file only turns those pure answers into LICE calls. It is DAW-verified, not unit-tested. +// SHELL: touches LICE and SWELL (HFONT). Palette decisions come from the pure `theme` +// module (role -> KitColor); layout/hit-test from the pure `component_geometry` / +// mode_switch / etc. modules. This file only turns those pure answers into LICE calls. +// DAW-verified, not unit-tested. // -// FONT LIFECYCLE (owned here): the kit holds a small set of LICE_CachedFonts (title / label -// / value-mono / micro). kitFontsInit() creates them once (from HFONTs handed off with -// LICE_FONT_FLAG_OWNS_HFONT, so the cached font frees the HFONT itself — verified in -// lice_text.h §SetFromHFont doc: "OWNS means LICE_IFont will clean up hfont on font change -// or exit"). kitFontsShutdown() deletes the cached fonts. The consumer calls init on panel -// open and shutdown on close/teardown. text() no-ops safely before init (defensive), so a -// draw that races construction never crashes. +// Fonts: kitFontsInit() hands each LICE_CachedFont an HFONT with +// LICE_FONT_FLAG_OWNS_HFONT, so the cached font frees the HFONT itself on shutdown/ +// reassignment. text() no-ops safely before init, so a draw that races construction +// never crashes. // -// DOUBLE-BUFFER DISCIPLINE (§3.5 "zero-jank"): every function here draws into the caller's -// offscreen LICE_IBitmap; the caller BitBlt's once. Nothing here draws direct-to-DC. +// Every function draws into the caller's offscreen LICE_IBitmap; the caller BitBlt's +// once. Nothing here draws direct-to-DC. -#include "core/ui/component_geometry.h" // KitBox / SliderGeometry — the pure geometry the shell draws -#include "core/audio/peaks.h" // Envelope — the waveform primitive's input +#include "core/ui/component_geometry.h" // KitBox / SliderGeometry +#include "core/audio/peaks.h" // Envelope #include "core/ui/theme.h" // Role / InteractionState / KitColor / TextClass -// LICE types at the boundary (this is the shell half). LICE_IBitmap is forward-declared -// to keep the header light. LICE_pixel is a typedef (unsigned int) — not forward-declarable -// — so the full lice.h is included only for the toLice() declaration; on Windows lice.h -// pulls in , which is fine since draw_kit.h is shell-only and never included by -// a pure module. +// LICE_pixel is a typedef (unsigned int), not forward-declarable, so the full lice.h is +// included for the toLice() declaration; lice.h pulls in on Windows, which is +// fine since this header is shell-only and never included by a pure module. #ifdef _WIN32 #include #endif @@ -40,10 +32,7 @@ class LICE_IBitmap; namespace reasampler { -// Real-namespace-home using-declarations (Q-W6: the interim core/namespaces.h shim -// is retired; the kit's pure vocabulary names its Q-W1 homes explicitly). These are -// deliberate re-exports: every draw_kit consumer speaks these types at the call -// boundary, so they surface here exactly as panel_state.h surfaces the panel's. +// Re-exports: every draw_kit consumer speaks these types at the call boundary. using audio::Envelope; using ui::InteractionState; using ui::KitBox; @@ -53,8 +42,8 @@ using ui::ListRowBox; using ui::Role; using ui::SliderGeometry; -// The kit's four cached fonts (§3.1 type scale). Consumers pass a Font to text() to pick -// the size/weight; the kit maps it to the matching LICE_CachedFont. +// The kit's four cached fonts. Consumers pass a Font to text() to pick the size/weight; +// the kit maps it to the matching LICE_CachedFont. enum class Font { Title, // ~15px semibold — region titles, headings Label, // ~12px regular — labels, body @@ -66,90 +55,59 @@ enum class Font { // single-line convention); a caller wanting multi-line composes rows itself. enum class Align { Left, Center, Right }; -// --- KitColor → LICE_pixel conversion ---------------------------------------- - -// The one place a pure KitColor becomes a LICE_pixel. Declared here so any shell -// translation unit that already includes draw_kit.h can use it without duplicating -// the LICE_RGBA packing. Defined in draw_kit.cpp. +// The one place a pure KitColor becomes a LICE_pixel. Defined in draw_kit.cpp. LICE_pixel toLice(const KitColor& c); -// --- Font lifecycle (owned by the kit) --------------------------------------- - -// Creates the four cached fonts once. Idempotent: a second call before shutdown is a no-op -// (the kit already holds live fonts). Safe to call on every panel open. Uses the platform -// UI sans (Segoe UI) for title/label/micro and a tabular mono (Consolas) for value-mono; -// the exact HFONT is created here, so a face change is a one-line edit. NO-OP-SAFE: if font -// creation fails, text() degrades to drawing nothing rather than crashing. +// Creates the four cached fonts once; idempotent. Segoe UI for title/label/micro, +// Consolas (tabular) for value-mono. No-op-safe: if font creation fails, text() +// draws nothing rather than crashing. void kitFontsInit(); -// Deletes the cached fonts (which free their owned HFONTs — LICE_FONT_FLAG_OWNS_HFONT). -// Idempotent. The consumer calls this on panel close / extension shutdown. +// Frees the owned HFONTs. Idempotent. Call on panel close / extension shutdown. void kitFontsShutdown(); -// --- Text (the single biggest "temple os -> modern" lever) ------------------- - -// Draws a single line of AA cached-font text in `color` inside `box`, horizontally aligned -// per `align` and vertically centered, clipped with an end-ellipsis. This REPLACES the -// GDI SetTextColor + DrawText path. No-op (safe) before kitFontsInit() or on a null bitmap. +// Draws a single line of AA cached-font text in `color` inside `box`, horizontally +// aligned per `align` and vertically centered, clipped with an end-ellipsis. No-op +// before kitFontsInit() or on a null bitmap. void text(LICE_IBitmap* bmp, const KitBox& box, const char* str, Font font, const KitColor& color, Align align); -// Convenience overload: text in a palette ROLE's color (the common case — the shell almost -// always wants text/primary or text/dim, not a raw color). +// Convenience overload: text in a palette ROLE's color. void text(LICE_IBitmap* bmp, const KitBox& box, const char* str, Font font, Role role, Align align); -// --- Surfaces + components ---------------------------------------------------- - -// The kit's foundational fill: a micro-gradient (a few percent lighter at the top, via -// LICE_GradRect) plus a 1px inner top-highlight and bottom-shadow — the vwnd trick that -// kills the flat look (§2.2). Every button/row/cell fills through this so elevation reads -// without a border. `role` picks the surface color; `state` transforms it per the -// interaction model (hover lightens, pressed darkens, disabled desaturates, etc.). +// The kit's foundational fill: a micro-gradient plus a 1px inner top-highlight/ +// bottom-shadow, so elevation reads without a border. `state` transforms the role +// color (hover lightens, pressed darkens, disabled desaturates). void fillSurface(LICE_IBitmap* bmp, const KitBox& box, Role role, InteractionState state); -// A rounded, gradient-filled button with the inner highlight/shadow and a centered label, -// honoring the interaction state. `warn == true` swaps the surface to the warn role (for -// byte-deleting verbs like prune/delete) — the only place warn is drawn. A degenerate box -// is a no-op. +// A rounded, gradient-filled button with a centered label. `warn == true` swaps the +// surface to the warn role — the only place warn is drawn. Degenerate box is a no-op. void drawButton(LICE_IBitmap* bmp, const KitButtonBox& button, const char* label, InteractionState state, bool warn); -// A horizontal slider: the track groove, the accent-filled portion up to the handle, and -// the handle (a raised knob honoring state — hover/dragging brighten it). `geom` is the -// pure SliderGeometry the caller computed; the kit only draws it. Degenerate geom is a no-op. +// A horizontal slider: track groove, accent-filled portion up to the handle, and the +// handle itself. `geom` is the pure SliderGeometry the caller computed. void drawSlider(LICE_IBitmap* bmp, const SliderGeometry& geom, InteractionState state); -// A selectable list row: the row surface (rest/hover/active/focus via state), an optional -// leading thumbnail area reserved at `thumbWidth` px (0 for none — the caller draws the -// thumbnail into the returned-by-convention left inset), and a left-aligned label in the -// remaining width. Focus draws a 1px text/primary ring distinct from the accent selection -// fill. A degenerate row is a no-op. +// A selectable list row: row surface, an optional leading thumbnail inset +// (`thumbWidth`, 0 for none), and a left-aligned label. Focus draws a 1px ring +// distinct from the accent selection fill. void drawListRow(LICE_IBitmap* bmp, const ListRowBox& row, const char* label, int thumbWidth, InteractionState state); -// waveformColumnCount — declared in component_geometry.h (already included above). Returns -// the drawable column count inside `box` (box.width minus the fixed 2px insets each side). -// Callers pass this value directly as the `binCount` argument to peaks::computeEnvelope; -// overbinning (more bins than columns) costs memory and CPU without changing a rendered -// pixel — peaks::columnMinMax's exact partition already makes the draw gap-free. - -// Multiplier kept at 1 (no oversampling). kWaveformOversample is present only so existing -// call sites `kWaveformOversample * waveformColumnCount(box)` compile unchanged; a value of -// 1 means they request exactly one bin per column, which is correct. The gap-free render -// comes from peaks::columnMinMax's exact partition, NOT from extra bins. +// Callers pass waveformColumnCount(box) as computeEnvelope's `binCount` — overbinning +// costs memory/CPU without changing a rendered pixel, since columnMinMax's exact +// partition already makes the draw gap-free at any bins-to-pixels ratio. Kept at 1 (no +// oversampling); present so existing call sites `kWaveformOversample * +// waveformColumnCount(box)` compile unchanged. inline constexpr int kWaveformOversample = 1; -// A waveform envelope drawn as a min/max plot over the bg/panel surface: a midline per -// channel and one accent vertical span PER PIXEL COLUMN, each column covering the true -// extremes of every bin that projects to it (peaks::columnMinMax — gap-free at any -// bins-to-pixels ratio because columnMinMax partitions bins exactly as computeEnvelope -// does, so every pixel column is always covered). The ONE waveform shape in the system: -// the dock-panel thumbnail, the browser cards, and the editor hero all render through -// this. `box` is the draw region; `env` is the per-channel min/max envelope from -// peaks::computeEnvelope, sized to waveformColumnCount(box) bins (clamped to frame count). -// An empty env draws just the midline. The caller fills the surface first (or passes a -// box already filled); this draws only the wave + midline. +// A waveform drawn as a min/max plot: a midline per channel and one accent vertical +// span per pixel column (peaks::columnMinMax — gap-free at any bins-to-pixels ratio). +// The ONE waveform shape in the system: dock-panel thumbnail, browser cards, and +// editor hero all render through this. `env` is sized to waveformColumnCount(box) +// bins (clamped to frame count); an empty env draws just the midline. void drawWaveform(LICE_IBitmap* bmp, const KitBox& box, const Envelope& env); } // namespace reasampler diff --git a/src/shell/panel/panel_audition.cpp b/src/shell/panel/panel_audition.cpp index d05136f..407563a 100644 --- a/src/shell/panel/panel_audition.cpp +++ b/src/shell/panel/panel_audition.cpp @@ -1,19 +1,14 @@ -// panel_audition.cpp — the audition/preview engine seam of the docked bank panel -// (Q-W2 split of bank_panel.cpp; M5 Wave B). HOT PATH GUARDRAIL (T4-28 / Q-W2): the -// preview path stays a DIRECT free-function call-through — no interface, no virtual -// dispatch, no added header->TU indirection; the idle path is unchanged in shape. +// panel_audition.cpp — the audition/preview engine seam of the docked bank panel. +// Hot-path guardrail: the preview path stays a direct free-function call-through — +// no interface, no virtual dispatch, no added header->TU indirection. // -// Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.h -// WITHOUT REAPERAPI_IMPLEMENT — main.cpp owns the API pointers; here they are -// extern (CLAUDE.md §contract). DAW-verified, not unit tested. +// DAW-verified, not unit tested. main.cpp owns the API pointers; here they are extern. #include #include "shell/panel/panel_state.h" -// 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. +// PlayPreview/StopPreview (stock, not SWS-only) drive a caller-owned preview_register_t. #define REAPERAPI_MINIMAL #define REAPERAPI_WANT_PlayPreview #define REAPERAPI_WANT_StopPreview @@ -23,26 +18,18 @@ namespace reasampler::panel { -// --- Audition preview --------------------------------------------------------- +// Audition is preview playback only — never inserts into the arrange or mutates +// the project/bank. PlayPreview streams a caller-owned PCM_source through +// REAPER's preview bus. // -// 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. +// Runtime assumptions not documented in the SDK header (DAW-verify, not asserted): +// 1. The audio thread reads preview_register_t by pointer while active, so it +// must outlive playback — held in g_panel (static), never on the stack. +// 2. StopPreview is assumed to detach the source before returning, so +// PCM_Source_Destroy immediately after is safe (SWS' preview helpers rely on +// the same contract). If a race ever surfaces, the fix is a StartPreviewFade +// + deferred free. +// 3. m_out_chan == 0 routes to the first hardware output pair (stereo, not mono). void initPreview() { if (g_panel.previewInited) return; @@ -76,8 +63,7 @@ void deinitPreview() { g_panel.previewInited = false; } -// Auditions the sample at selection ordinal `idx` of the FOCUSED region's displayed bank. -// L7: `idx` is a DISPLAY-order (slot) ordinal, resolved through orderedIds, not a raw +// `idx` is a display-order (slot) ordinal, resolved through orderedIds, not a raw // BankModel position. void startAudition(int idx) { stopAudition(); diff --git a/src/shell/panel/panel_bank_ops.cpp b/src/shell/panel/panel_bank_ops.cpp index 9b4fce0..0ce2fbb 100644 --- a/src/shell/panel/panel_bank_ops.cpp +++ b/src/shell/panel/panel_bank_ops.cpp @@ -1,14 +1,12 @@ -// panel_bank_ops.cpp — the bank-CRUD-UX + menus seam of the docked bank panel -// (Q-W2 split of the former bank_panel god-module; Phase B4/B5). Since Q-W6 the -// promptless bank verbs live in shell/bank_ops (model op + persistBankOp, taking -// ReaSamplerSession&); this TU is the panel's THIN UX SKIN over them — the menu -// handlers (prompts / confirms / message boxes / panel-state nudges / repaint), -// the book/bank accessors, the popup menus that drive them, and the selection-id / -// OS-drag path resolvers. The bindable bank_actions family is the sibling skin. +// panel_bank_ops.cpp — the bank-CRUD-UX + menus seam of the docked bank panel. The +// promptless bank verbs live in shell/bank_ops (bankOp* + persistBankOp); this TU is +// the panel's THIN UX SKIN over them — menu handlers, book/bank accessors, popup +// menus, and the selection-id / OS-drag path resolvers. `bank_actions` is the +// sibling bindable-action skin. // // Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.h -// WITHOUT REAPERAPI_IMPLEMENT — main.cpp owns the API pointers; here they are -// extern (CLAUDE.md §contract). DAW-verified, not unit tested. +// WITHOUT REAPERAPI_IMPLEMENT — main.cpp owns the API pointers. DAW-verified, not +// unit-tested. #include #include @@ -18,7 +16,7 @@ #include "shell/panel/panel_state.h" #include "shell/panel/panel_bank_ops.h" -#include "shell/bank_ops/bank_ops.h" // bankOp* promptless verbs (the Q-W6 non-UI seam) +#include "shell/bank_ops/bank_ops.h" // bankOp* promptless verbs #include "shell/persist/session.h" // ReaSamplerSession — the live session the ops mutate #define REAPERAPI_MINIMAL @@ -32,7 +30,7 @@ namespace reasampler::panel { namespace fs = std::filesystem; -// --- Current-project directory (mirrors the persist shell's derivation, ext_state_io.cpp) +// Mirrors the persist shell's derivation (ext_state_io.cpp). std::string currentProjectDir() { std::vector buf(4096, '\0'); EnumProjects(-1, buf.data(), static_cast(buf.size())); @@ -41,8 +39,6 @@ std::string currentProjectDir() { return normalizeSlashes(fs::path(rpp).parent_path().string()); } -// --- Book / bank accessors ---------------------------------------------------- - BankBook* book() { return g_panel.session ? &g_panel.session->book() : nullptr; } // The BankModel a region currently displays. Pool region -> the pool; banks region -> @@ -72,17 +68,9 @@ std::vector namedBanks() { return out; } -// --- Bank management ops (id-keyed; THIN UX SKINS over the bankOp* verbs) ------ -// -// Q-W4/Q-W6: each handler here owns only the panel's UX (prompts / confirms / -// message boxes / panel-state nudges / repaint); the model op + persist is the -// shared bankOp* inner verb (shell/bank_ops), which takes the live session by -// reference — the book() check answers the one session-liveness question per -// handler. After a STRUCTURAL mutation (create/delete/evacuate) any -// Bank*/BankModel& is invalid — we resolve fresh, pass ids, and let the next -// refreshFingerprint repaint. On an unsaved project the empty-close discard in -// persistBankOp ensures no stale state survives (matches the capture/B3 -// quiet-persist idiom). +// Bank management ops (id-keyed; THIN UX SKINS over the bankOp* verbs). After a +// STRUCTURAL mutation (create/delete/evacuate) any Bank*/BankModel& is invalid — we +// resolve fresh, pass ids, and let the next refreshFingerprint repaint. void doCreateBank() { if (!book()) return; @@ -116,9 +104,9 @@ void doRenameBank(const std::string& bankId) { invalidatePanel(); } -// Delete with the RICHER confirm-on-non-empty affordance (B4): the confirm names the -// member count AND offers evacuate as the one-click alternative (Yes=delete anyway, -// No=evacuate-then-keep, Cancel=abort) — richer than B3's basic YESNO. +// Delete with a confirm-on-non-empty affordance: the confirm names the member count +// and offers evacuate as the one-click alternative (Yes=delete anyway, +// No=evacuate-then-keep, Cancel=abort). void doDeleteBank(const std::string& bankId) { if (!book()) return; const Bank* bk = book()->bank(bankId); @@ -144,13 +132,10 @@ void doDeleteBank(const std::string& bankId) { } // r == 6 (Yes) falls through to a plain delete (drops members). } - // S9: bump when the bank held samples (either the Yes-drop path or the No-evacuate-then- - // delete path moved/dropped members) — both change what a live instance could play. An - // empty-bank delete is purely organizational, no bump. The ORIGINAL member count decides - // (the No-path evacuated them moments ago, but the membership still changed). + // Bump generation when the bank held samples — an empty-bank delete is purely + // organizational. The ORIGINAL member count decides (the No-path already evacuated them). if (!bankOpDelete(*g_panel.session, bankId, /*bumpGeneration=*/members > 0)) return; - // shownBankId is reconciled by the next fingerprint pass. If no named banks remain, - // nudge focus to the pool so the selection has a valid home. + // If no named banks remain, nudge focus to the pool so the selection has a valid home. if (namedBanks().empty()) g_panel.focusedRegion = Region::Pool; invalidatePanel(); } @@ -172,40 +157,33 @@ void doActivateBank(const std::string& bankId) { } // namespace // Move or copy `sampleIds` from `srcBankId` to `destBankId` (index-only). Thin panel -// skin over bankOpTransfer (the one-home verb owns the loop, the verb-aware no-op -// guardrail, and the undo-batched persist); this layer clears the stale selection -// and repaints on an actual mutation. +// skin over bankOpTransfer; clears the stale selection and repaints on an actual mutation. void transferSamples(const std::vector& sampleIds, const std::string& srcBankId, const std::string& destBankId, bool copy) { if (!book()) return; // no live session — nothing to transfer within if (!bankOpTransfer(*g_panel.session, sampleIds, srcBankId, destBankId, copy)) return; // nothing changed — no persist, no undo point - // The selection indexed into the source; after a move those indices are stale, so - // clear it (the fingerprint pass will also clear, but do it now for immediacy). + // Selection indexed into the source; after a move those indices are stale. g_panel.selection = Selection{}; invalidatePanel(); } // Remove `sampleIds` from `srcBankId` (index-only, this-bank scope). Thin panel skin -// over bankOpRemove — see the verb for the never-deletes-bytes / silent-remove / -// one-Ctrl-Z contract. Clears the stale selection and repaints on an actual removal. +// over bankOpRemove. Clears the stale selection and repaints on an actual removal. void removeSamples(const std::vector& sampleIds, const std::string& srcBankId) { if (!book()) return; // no live session — nothing to remove from if (!bankOpRemove(*g_panel.session, sampleIds, srcBankId)) return; // nothing changed — no persist, no undo point - // The selection indexed into the source; after a remove those indices are stale, so - // clear it (the fingerprint pass will also clear, but do it now for immediacy). g_panel.selection = Selection{}; invalidatePanel(); } // The selection's sample ids resolved against the FOCUSED region's bank (source of a -// move/copy). Returns ids in bank order; empty when nothing selected. +// move/copy). Selection ordinals index the DISPLAY (slot) order, not BankModel +// insertion order. Returns ids in bank order; empty when nothing selected. std::vector focusedSelectionIds() { - // L7: selection ordinals index the DISPLAY (slot) order, not BankModel insertion order. - // orderedIds[i] is the id at selection ordinal i. std::vector ids; const RegionDisplay disp = focusedDisplay(); const int count = disp.occupiedCount(); @@ -214,14 +192,11 @@ std::vector focusedSelectionIds() { return ids; } -// Resolves the ARMED drag payload (g_panel.dragSampleIds, from g_panel.dragSourceBankId) to -// the absolute, existing-file path list for a native OS drag-out (M11). Reuses the SAME M4 -// path machinery the panel uses for audition/insert (resolveBankFile over the current -// project dir) — no temp copies; the drag points straight at the on-disk bank files. Each -// id is looked up in its SOURCE bank's index (the payload's origin, not the focused region, -// which can differ once the pointer roams), resolved, stat'd, then handed to the pure -// drag_out::assemblePathList for dedupe + skip-missing/unresolved policy. Read-only: no -// mutation of sample / index / selection (invariant #2). +// Resolves the ARMED drag payload to the absolute, existing-file path list for a native +// OS drag-out. Reuses resolveBankFile (audition/insert's path machinery) — no temp +// copies. Each id is looked up in its SOURCE bank's index (not the focused region, which +// can differ once the pointer roams), then handed to drag_out::assemblePathList for +// dedupe + skip-missing/unresolved policy. Read-only. std::vector resolveDragPathsForOs() { std::vector resolved; BankBook* b = book(); @@ -242,19 +217,13 @@ std::vector resolveDragPathsForOs() { return assemblePathList(resolved).paths; } -// --- Popup menus -------------------------------------------------------------- -// -// SWELL/Win32 both expose CreatePopupMenu / InsertMenu (SWELL aliases SWELL_InsertMenu -// -> InsertMenu) / TrackPopupMenu(TPM_RETURNCMD) / DestroyMenu. We build a menu of -// (label -> small int command), track it at screen coords, and switch on the return. -// Menu command ids are LOCAL to the popup (not REAPER action ids) — TPM_RETURNCMD -// hands the chosen id straight back, so no hookcommand routing is involved. +// SWELL/Win32 both expose CreatePopupMenu / InsertMenu / TrackPopupMenu(TPM_RETURNCMD) / +// DestroyMenu. Menu command ids below are LOCAL to the popup (not REAPER action ids) — +// TPM_RETURNCMD hands the chosen id straight back, so no hookcommand routing is involved. namespace { -// Appends a string item (id) to `menu` at its end. Portable over Win32/SWELL: both -// accept InsertMenu(menu, pos, MF_BYPOSITION|MF_STRING, id, text) with a negative -// position appending. Win32 and SWELL both treat pos < 0 as an append. +// Win32 and SWELL both treat pos < 0 as append. void menuAppend(HMENU menu, unsigned int id, const char* text, bool grayed = false) { UINT flags = MF_BYPOSITION | MF_STRING; if (grayed) flags |= MF_GRAYED; @@ -272,7 +241,7 @@ enum : unsigned int { kMenuDelete, kMenuEvacuate, kMenuCreate, - kMenuRemove, // remove selected sample(s) from the source bank (B5) + kMenuRemove, // remove selected sample(s) from the source bank kMenuMoveBase = 1000, // move-to-bank: kMenuMoveBase + destination index kMenuCopyBase = 2000, // copy-to-bank: kMenuCopyBase + destination index }; @@ -313,11 +282,10 @@ void showTabMenu(int screenX, int screenY, const std::string& bankId) { } } -// Opens the top-toolbar overflow ("⋯" More) popup at the button's screen position and fires the -// chosen rare-capture variant's command (L5 refinement 1). Menu ids are LOCAL to the popup -// (1-based ordinal into overflowMenuRows); TPM_RETURNCMD hands the chosen id back, then we -// resolve + fire the corresponding registered command id via the SAME contract the visible -// buttons use. Defined here (after menuAppend/menuSeparator); forward-declared above. +// Opens the top-toolbar overflow ("⋯" More) popup and fires the chosen rare-capture +// variant's command. Menu ids are LOCAL to the popup (1-based ordinal into +// overflowMenuRows); we resolve + fire the corresponding registered command id via +// the same contract the visible buttons use. void showMoreMenu() { if (!g_panel.hwnd) return; const std::vector rows = overflowMenuRows(); @@ -349,9 +317,8 @@ void showMoreMenu() { } // Shows the move/copy menu for the current selection (the SOURCE is the focused -// region's bank). Lists every OTHER bank (pool + named) as a move destination, then a -// copy submenu-free flat list (copy entries follow the move block). Move is the -// default (listed first); copy is the deliberate secondary act. +// region's bank). Lists every OTHER bank as a move destination, then the same list +// as a copy destination. Move is the default (listed first); copy is secondary. void showSelectionMenu(int screenX, int screenY) { const std::vector sel = focusedSelectionIds(); if (sel.empty()) return; @@ -401,18 +368,14 @@ void showSelectionMenu(int screenX, int screenY) { } // namespace reasampler::panel -// --- Public API (panel_bank_ops.h) --------------------------------------------- - namespace reasampler { -// One home (Q-W4) for the former actions/panel byte-identical twins. -// COMMA GUARD: GetUserInputs splits returned values on a separator defaulting to ',', -// so the return separator is overridden to \x1f (un-typeable) via the documented -// `separator=X` trailing pseudo-caption (SDK ~3806) — any printable name round-trips. +// GetUserInputs splits returned values on a separator defaulting to ',', so the +// separator is overridden to \x1f (un-typeable) via the documented `separator=X` +// trailing pseudo-caption — any printable name round-trips. bool promptBankName(const char* title, const char* caption, const std::string& initial, std::string& out) { std::vector buf(512, '\0'); - // Pre-fill: GetUserInputs seeds the field from the retvals buffer's initial value. std::snprintf(buf.data(), buf.size(), "%s", initial.c_str()); const std::string captions = std::string(caption) + ",separator=\x1f"; if (!GetUserInputs(title, 1, captions.c_str(), buf.data(), @@ -424,8 +387,6 @@ bool promptBankName(const char* title, const char* caption, const std::string& i return true; } -// --- Selection read seam -------------------------------------------------------- - std::vector bankPanelSelectedSampleIds() { return panel::focusedSelectionIds(); } diff --git a/src/shell/panel/panel_bank_ops.h b/src/shell/panel/panel_bank_ops.h index 73877d0..8179b8e 100644 --- a/src/shell/panel/panel_bank_ops.h +++ b/src/shell/panel/panel_bank_ops.h @@ -1,55 +1,25 @@ #pragma once -// panel_bank_ops — the bank-CRUD-UX + selection-read seam of the bank panel (Q-W2 -// split of the former bank_panel god-module; Phase B4/B5). Since Q-W6 the -// promptless bank verbs themselves live in the NON-UI shell/bank_ops seam -// (bankOp* + persistBankOp, taking ReaSamplerSession&); this TU is the panel's -// thin UX skin over them — prompts / confirms / message boxes / panel-state -// nudges / repaints — plus the popup menus that drive them. The bank_actions -// bindable family is the sibling skin over the same verbs. This header carries -// the shared prompt helper and the panel's public selection-read surface. -// -// The selection reads are REAPER-free; the prompt helper is REAPER-facing (stock -// dialogs) but SDK-free in this header. +// panel_bank_ops — bank-CRUD-UX + selection-read seam of the bank panel. The +// promptless verbs themselves live in shell/bank_ops (bankOp* + persistBankOp); +// this TU is the panel's thin prompt/confirm/repaint skin over them, plus the +// popup menus that drive them. `bank_actions` is the sibling skin. #include #include namespace reasampler { -// Prompts the user for a single line of text via REAPER's stock input dialog -// (GetUserInputs). `initial` pre-fills the field. Returns false (leaving `out` -// untouched) on cancel or an empty entry. COMMA GUARD: the return separator is -// overridden to \x1f (un-typeable) via the documented `separator=X` pseudo-caption, -// so any printable name — commas included — round-trips whole (SDK ~3806/3808). -// One home (Q-W4) for the former actions/panel byte-identical twins; shared by the -// panel menus and the bank_actions bindable family. +// Via GetUserInputs. Returns false (leaving `out` untouched) on cancel or empty +// entry. Return separator is overridden to \x1f so any printable name round-trips. bool promptBankName(const char* title, const char* caption, const std::string& initial, std::string& out); -// The stable ids of the currently-selected samples, in bank (insertion) order. -// Empty when nothing is selected or the panel has never opened. This is the clean -// seam the `insert` action reads to know WHAT to place — it returns ids (not grid -// indices) so the caller resolves against the live bank and is unaffected by the -// panel's internal index bookkeeping. READ of panel state only; no mutation. -// -// Note: the panel's selection is cleared on a bank change (capture / project -// load), so a returned id always names a sample present in the current bank at -// the moment of the call; the caller still tolerates an absent id gracefully. -// -// Phase B4 (vertical split): the selection lives in whichever REGION the user last -// interacted with (the pool grid on top or a named-bank grid below), which is NOT -// necessarily the active/capture-target bank. The returned ids therefore name -// samples in the FOCUSED region's displayed bank — the bank the user visibly -// selected in. Pair with bankPanelSelectedSourceBankId() to know which bank those -// ids belong to (the move/copy source). +// Ids in bank (insertion) order, not grid indices. Empty when nothing is selected. +// Pair with bankPanelSelectedSourceBankId() to know which bank these belong to. std::vector bankPanelSelectedSampleIds(); -// The bank id the current selection belongs to — the displayed bank of the region -// the user last interacted with (pool region -> the pool id; named-banks region -> -// the shown tab's bank id). This is the SOURCE bank for a move/copy of the current -// selection, and it is distinct from the active/capture-target bank (active ≠ shown). -// Returns the pool id when nothing is selected or the panel has never opened (a safe -// default source). READ of panel state only; no mutation. +// The displayed bank of the region the user last interacted with — the SOURCE +// bank for a move/copy. Returns the pool id as a safe default. std::string bankPanelSelectedSourceBankId(); } // namespace reasampler diff --git a/src/shell/panel/panel_drag.cpp b/src/shell/panel/panel_drag.cpp index 8edda00..9f0d5db 100644 --- a/src/shell/panel/panel_drag.cpp +++ b/src/shell/panel/panel_drag.cpp @@ -1,18 +1,13 @@ -// panel_drag.cpp — the card-drag / hover state machine seam of the docked bank panel -// (Q-W2 split of bank_panel.cpp; the T4-01 NEW seam; M11/L7/S17). Owns WM_MOUSEMOVE -// (hover resolution + tooltip timing + the live drag), the drop-target/gesture -// classification, the cursor cues, button-up drop dispatch (reorder / replace / -// move / copy / instrument-drop / OS drag-out), and right-click menu routing. Its -// PURE mirror is core/ui/card_drag (gesture precedence + slot hit-test) with -// core/ui/drag_out owning the OS-drag boundary decision — this shell supplies only -// the live rects, modifier state, and side effects. -// -// PER-MOUSE-MOVE GUARDRAIL (T4-28): everything on the move path stays plain +// panel_drag.cpp — the card-drag / hover state machine seam of the docked bank panel: +// WM_MOUSEMOVE (hover + tooltip timing + the live drag), drop-target/gesture +// classification, cursor cues, button-up drop dispatch, and right-click menu routing. +// Its PURE mirror is core/ui/card_drag (gesture precedence + slot hit-test), with +// core/ui/drag_out owning the OS-drag boundary decision — this shell supplies only the +// live rects, modifier state, and side effects. Per-mouse-move work stays plain // free-function calls — no interface, no virtual dispatch. // // Compiled into the reaper_reasampler MODULE. No REAPER API functions are called -// directly here (the FX-hotspot / OS-drag / instrument-drop shells own theirs); -// REAPER SDK types arrive via panel_state.h. +// directly here; REAPER SDK types arrive via panel_state.h. #include // std::abs (drag threshold) #include @@ -20,14 +15,12 @@ #include "shell/panel/panel_state.h" -#include "shell/bank_ops/bank_ops.h" // persistBankOp — shared undo-block wrapper (R-B) -#include "shell/actions/drag_out_win.h" // OLE / SWELL drag-out initiation seam (M11) -#include "shell/actions/instrument_drop_win.h" // resolveFxDropTarget / performInstrumentDrop (S17) +#include "shell/bank_ops/bank_ops.h" // persistBankOp — shared undo-block wrapper +#include "shell/actions/drag_out_win.h" // OLE / SWELL drag-out initiation seam +#include "shell/actions/instrument_drop_win.h" // resolveFxDropTarget / performInstrumentDrop namespace reasampler::panel { -// --- Drag (move between regions/onto a tab) ----------------------------------- - constexpr int kDragThreshold = 5; // px the pointer must move to begin a drag namespace { @@ -55,9 +48,7 @@ void updateDropTarget(int x, int y) { g_panel.dropBankId = tabs[static_cast(hit.index)]->id; return; } - // Tab takes precedence over the region; if the point is in the banks region but - // not on a specific tab, treat the whole grid as a drop zone for the shown bank. - // No valid target when there are no named banks or no shown bank. + // Tab takes precedence; otherwise the whole grid is a drop zone for the shown bank. if (!g_panel.shownBankId.empty() && book() && book()->bank(g_panel.shownBankId)) { if (x >= br.left && x < br.right && y >= br.top && y < br.bottom) { g_panel.dropKind = DropKind::BanksRegion; @@ -76,9 +67,8 @@ void updateDropTarget(int x, int y) { } } -// The destination bank id under the current drop target (pool id for PoolRegion; the tab/ -// shown-bank id for Tab/BanksRegion; "" for no target). Derived from updateDropTarget's -// dropKind/dropBankId — the single source of "what bank is under the pointer". +// The destination bank id under the current drop target (pool id for PoolRegion; the +// tab/shown-bank id for Tab/BanksRegion; "" for no target). std::string dropTargetBankId() { switch (g_panel.dropKind) { case DropKind::PoolRegion: return std::string(kPoolBankId); @@ -89,13 +79,12 @@ std::string dropTargetBankId() { return {}; } -// L7: classifies the live in-grid drag into a pure CardGesture and (for a same-bank drop) -// the target slot, updating g_panel.cardGesture / dragTargetSlot. Call AFTER updateDropTarget -// so dropKind/dropBankId are current. The pure card_drag::decideCardGesture owns the -// precedence (leave-client -> OS; other-bank -> move/copy; same-bank grid -> reorder/replace); -// the shell only supplies the region verdict, the same-bank target slot + occupancy, and the -// live modifier state. The OS-drag-out boundary is handled by the existing decideGesture path -// in onMouseMove BEFORE this runs, so here the pointer is always inside the client. +// Classifies the live in-grid drag into a pure CardGesture and (for a same-bank drop) +// the target slot. Call AFTER updateDropTarget so dropKind/dropBankId are current. +// card_drag::decideCardGesture owns the precedence (other-bank -> move/copy; same-bank +// grid -> reorder/replace); this only supplies the region verdict, target slot + +// occupancy, and modifier state (the OS-drag-out boundary is handled earlier, in +// onMouseMove, so here the pointer is always inside). void classifyCardDrag(int x, int y) { g_panel.cardGesture = CardGesture::None; g_panel.dragTargetSlot = -1; @@ -111,10 +100,9 @@ void classifyCardDrag(int x, int y) { mods.alt = altDown(); if (!destBank.empty() && destBank == g_panel.dragSourceBankId) { - // Same-bank grid: a reorder/replace target. Resolve the slot the pointer sits over - // in the SOURCE bank's own region display + whether it is occupied. - // Uses computeSlotRectsForDrop (one trailing row past maxSlot) so a drop beyond - // the last occupied card resolves to a valid trailing slot, not a -1 miss. + // Same-bank grid: a reorder/replace target. Resolve the slot + occupancy in the + // SOURCE bank's display. computeSlotRectsForDrop adds one trailing row past + // maxSlot so a drop beyond the last card resolves to a valid slot, not a -1 miss. mods.region = DropRegion::SameBankGrid; const bool isBanks = g_panel.dragSourceRegion == Region::Banks; const RECT region = isBanks ? banksRegionRect(w, h) : poolRegionRect(w, h); @@ -141,16 +129,11 @@ void classifyCardDrag(int x, int y) { g_panel.cardGesture = decideCardGesture(x, y, client, st, mods); } -// Maps the pure L7 cursor cue to a SWELL stock cursor and sets it. The cue DECISION is pure -// (card_drag::cursorForGesture); the shell owns only this SetCursor call + the resource choice. -// Stock SWELL cursors (vendor/WDL/WDL/swell/swell-types.h:1320-1329, mirroring the Win32 OCR_* -// set): Reorder -> IDC_SIZEALL (four-way move, the file-manager reorder idiom); Move -> -// IDC_HAND (grab-and-place to another bank/tab); Copy -> IDC_UPARROW (no stock copy cursor -// exists cross-platform — this is the closest distinct stock cue; a bespoke copy cursor would -// need a resource file, deliberately NOT added); Replace -> IDC_SIZEWE (a distinct "swap -// occupant" cue, shown ONLY when the pure result is Replace, i.e. Alt over an occupied slot); -// OsDragOut -> the OS drag loop owns the cursor once handed off, so leave it (arrow here is -// never seen — the handoff happens before this runs); Default/None -> IDC_ARROW. +// Maps the pure cursor cue to a SWELL stock cursor (vendor/WDL/WDL/swell/swell-types.h, +// mirroring Win32 OCR_*) and sets it; the cue decision is pure (card_drag::cursorForGesture). +// Copy has no stock cross-platform cursor, so IDC_UPARROW is the closest distinct stock cue +// (a bespoke resource was deliberately not added). OsDragOut leaves the cursor alone — the +// OS drag loop owns it once handed off, and this branch is never actually seen. void applyDragCursor(CardGesture g) { const char* idc = IDC_ARROW; switch (cursorForGesture(g)) { @@ -164,26 +147,23 @@ void applyDragCursor(CardGesture g) { SetCursor(LoadCursor(nullptr, idc)); } -// Resolves the topmost INTERACTIVE element under client (x, y) for hover feedback, mirroring -// handleClick's precedence exactly (so the element that lights on hover is the one a click -// would hit). Returns HoverKind::None for the grid / dead space / a point outside the client -// (the grid cells carry their own selection/focus chrome, not a kit hover surface). Pure -// resolution over the same pure geometry the click path uses. +// Resolves the topmost INTERACTIVE element under client (x, y) for hover feedback, +// mirroring handleClick's precedence exactly (so the element that lights on hover is +// the one a click would hit). Returns HoverKind::None for the grid / dead space (the +// grid cells carry their own selection/focus chrome, not a kit hover surface). Hover resolveHover(int x, int y) { if (!g_panel.hwnd) return Hover{}; RECT cr{}; GetClientRect(g_panel.hwnd, &cr); const int w = cr.right - cr.left, h = cr.bottom - cr.top; - // TOP toolbar: the far-right More button, then the frequent buttons (matching the click - // order — first zone top-to-bottom). + // TOP toolbar, then footer, then BOTTOM toolbar, matching handleClick's precedence. { const MenuButtonRect mb = topMenuButtonRect(w); if (hitTestMenuButton(x, y, mb)) return Hover{HoverKind::MoreButton, -1}; const int hit = toolbarHit(x, y, topToolbarActionRect(w), topBarRows()); if (hit >= 0) return Hover{HoverKind::TopBarButton, hit}; } - // Footer: mode-toggle segments, Tail button, then Prune (matching the click order). { const int seg = footerToggleSegmentHit(x, y, w, h); if (seg >= 0) return Hover{HoverKind::ModeSegment, seg}; @@ -192,12 +172,10 @@ Hover resolveHover(int x, int y) { const ButtonRect pb = pruneButtonRectFor(w, h); if (hitTestPruneButton(x, y, pb)) return Hover{HoverKind::PruneButton, -1}; } - // BOTTOM toolbar buttons. { const int hit = toolbarHit(x, y, bottomToolbarRect(w, h), bottomBarRows()); if (hit >= 0) return Hover{HoverKind::BottomBarButton, hit}; } - // Region chrome: full-height toggles, create button, tabs. if (poolShown()) { const RECT pr = poolRegionRect(w, h); const RECT ftb = fullHtBtnRect(pr); @@ -222,10 +200,10 @@ Hover resolveHover(int x, int y) { } // Updates the live hover element and repaints ONLY on a change (sub-frame feedback, no -// per-move jank — the "speed is the selling point" repaint discipline). L5: a hover CHANGE also -// resets the tooltip timer (hoverSinceTick) and hides any shown tooltip, so the tooltip only -// appears after the pointer rests kTooltipDelayMs on ONE element (the delay is applied by the -// poll tick in maybeShowTooltip). A move within the SAME element leaves the timer running. +// per-move jank). A hover CHANGE also resets the tooltip timer (hoverSinceTick) and hides +// any shown tooltip, so the tooltip only appears after the pointer rests kTooltipDelayMs +// on ONE element (applied by the poll tick in maybeShowTooltip); a move within the SAME +// element leaves the timer running. void updateHover(int x, int y) { const Hover next = resolveHover(x, y); if (next != g_panel.hovered) { @@ -239,10 +217,9 @@ void updateHover(int x, int y) { } // namespace // Applies the tooltip hover-delay: if a tooltip-bearing element has been hovered past -// kTooltipDelayMs and the tooltip is not yet shown, latch it and repaint once. Driven from the -// OnTimer poll (bankPanelRefresh) so the tooltip appears after a rest with no dedicated timer; -// WM_MOUSEMOVE's updateHover resets the timer, so a moving pointer never trips it. No-op when the -// current hover has no tooltip (grid / chrome / the More button). +// kTooltipDelayMs and the tooltip is not yet shown, latch it and repaint once. Driven from +// the OnTimer poll (bankPanelRefresh) so the tooltip appears after a rest with no dedicated +// timer; updateHover resets the timer on every move, so a moving pointer never trips it. void maybeShowTooltip() { if (g_panel.tooltipShown) return; const HoverKind k = g_panel.hovered.kind; @@ -255,9 +232,8 @@ void maybeShowTooltip() { } void onMouseMove(int x, int y) { - // Hover feedback (L2): resolve + repaint-on-change, but NOT during a drag (the drag owns - // the visual feedback then — a drop-target highlight, not a hover). Cleared to None when - // the pointer is over the grid / dead space. + // Hover feedback: resolve + repaint-on-change, but NOT during a drag (the drag owns + // the visual feedback then — a drop-target highlight, not a hover). if (!g_panel.dragging && !g_panel.dragArmed) updateHover(x, y); if (g_panel.dragArmed && !g_panel.dragging) { @@ -267,9 +243,9 @@ void onMouseMove(int x, int y) { g_panel.dragging = true; g_panel.dragSourceBankId = bankIdForRegion(g_panel.dragSourceRegion); g_panel.dragSampleIds = focusedSelectionIds(); - // The single card actually grabbed = the focus ordinal's id. This is the L7 - // in-grid reorder/replace subject (see onLBtnUp) — "drag a card" is a single-card - // gesture, distinct from the multi-select move/copy payload in dragSampleIds. + // The single card actually grabbed = the focus ordinal's id — the in-grid + // reorder/replace subject (see onLBtnUp), distinct from the multi-select + // move/copy payload in dragSampleIds. { const RegionDisplay disp = focusedDisplay(); const int f = g_panel.selection.focus; @@ -283,13 +259,9 @@ void onMouseMove(int x, int y) { } } if (g_panel.dragging) { - // M11 gesture boundary, REFINED by S17. While a drag with samples is under way and the - // pointer is INSIDE the client rect it stays the internal bank-to-bank drag (invariant - // #4, byte-identical). Once it LEAVES the client rect the pure drag_out::decideGesture - // splits the outside case three ways: a single-capture drag over REAPER's OWN UI is an - // InstrumentDrop (hover-track the FX button, drop on release); a multi-capture drag OR a - // pointer that has left REAPER entirely is the unchanged M11 OsDrag; inside stays - // Internal. The shell supplies the "over REAPER's UI" predicate via GetThingFromPoint. + // Inside the client rect it stays the internal bank-to-bank drag. Once it LEAVES, + // drag_out::decideGesture splits three ways: single-capture over REAPER's OWN UI -> + // InstrumentDrop; multi-capture or fully outside REAPER -> OsDrag; inside -> Internal. RECT cr{}; GetClientRect(g_panel.hwnd, &cr); const PanelClientRect client{cr.left, cr.top, cr.right - cr.left, cr.bottom - cr.top}; @@ -298,10 +270,8 @@ void onMouseMove(int x, int y) { DragState st{/*dragging=*/true, /*hasArmedSamples=*/!g_panel.dragSampleIds.empty()}; st.singleCapture = (g_panel.dragSampleIds.size() == 1); - // Resolve the FX drop target only when OUTSIDE the client rect (the S17 middle case can - // only arise there) and only for a single-capture payload — the SDK hit-test is skipped - // on the common internal-drag path so it costs nothing there. The screen conversion is - // Windows-only (D5); resolveFxDropTarget owns the REAPER hit query. + // Only resolved OUTSIDE the client rect and for a single-capture payload, so the SDK + // hit-test costs nothing on the common internal-drag path. FxDropTarget fx; if (!inside && st.singleCapture) { POINT sp{x, y}; @@ -313,11 +283,9 @@ void onMouseMove(int x, int y) { const DragGesture gesture = decideGesture(x, y, client, st); if (gesture == DragGesture::InstrumentDrop) { - // Track the FX hotspot for the release; the highlight is REAPER's own FX-button - // hover feedback under the pointer (the drop is driven on button-up). We keep the - // internal-drag capture alive so we keep receiving moves (unlike OsDrag, this does - // NOT hand off to a modal OS loop). Clear any internal drop-target highlight so the - // panel does not also paint a bank-drop cue while the drag is out over a track. + // Track the FX hotspot for the release. Unlike OsDrag this does NOT hand off to + // a modal OS loop, so the internal-drag capture stays alive; clear any bank + // drop-target highlight so the panel doesn't paint that cue too. g_panel.instrumentDropTrack = fx.valid() ? fx.track : nullptr; g_panel.dropKind = DropKind::None; g_panel.dropBankId.clear(); @@ -333,11 +301,8 @@ void onMouseMove(int x, int y) { // drag state (the resolver reads dragSourceBankId / dragSampleIds). const std::vector paths = resolveDragPathsForOs(); - // Reset internal drag state and release capture NOW: DoDragDrop runs its own - // modal loop and takes over mouse capture, so the internal drag must be fully - // wound down first (no stale dragging/dropKind, no lingering SetCapture). A - // cancelled/empty OS drag therefore leaves the panel in a clean, no-op state - // (invariant #2 — nothing mutated). + // DoDragDrop runs its own modal loop and takes over mouse capture, so the internal + // drag must be fully wound down first. if (GetCapture() == g_panel.hwnd) ReleaseCapture(); g_panel.dragArmed = false; g_panel.dragging = false; @@ -353,9 +318,9 @@ void onMouseMove(int x, int y) { initiateDragOut(g_panel.hwnd, paths); // COPY-ONLY; blocking on Windows return; } - // Inside the client: classify the in-grid gesture (L7 reorder/replace vs the existing - // move/copy) and reflect it as a cursor cue. updateDropTarget first so dropKind/ - // dropBankId are current for classifyCardDrag's same-vs-other-bank decision. + // Inside the client: classify the in-grid gesture (reorder/replace vs move/copy) and + // reflect it as a cursor cue. updateDropTarget first so dropKind/dropBankId are + // current for classifyCardDrag's same-vs-other-bank decision. updateDropTarget(x, y); classifyCardDrag(x, y); applyDragCursor(g_panel.cardGesture); @@ -363,12 +328,10 @@ void onMouseMove(int x, int y) { } } -// L7 in-grid REORDER drop: move the grabbed card to targetSlot within its bank (gap- -// preserving; onto a gap = place there, onto an occupant = insert-before-and-shift — the pure -// BankBook::reorderSample owns the semantics). One drop = one Ctrl-Z (persistBankOp opens the -// batched undo point + saves). A no-op reorder (already at the target, model returns false) -// opens no undo point. Selection reasons over slot order, so it is cleared after — the -// fingerprint pass rebuilds it against the new order. +// In-grid REORDER drop: move the grabbed card to targetSlot within its bank (gap- +// preserving; onto a gap = place there, onto an occupant = insert-before-and-shift — the +// pure BankBook::reorderSample owns the semantics). One drop = one Ctrl-Z. A no-op reorder +// opens no undo point. Selection reasons over slot order, so it is cleared after. namespace { void doReorderDrop(const std::string& id, const std::string& bankId, int targetSlot) { @@ -379,10 +342,9 @@ void doReorderDrop(const std::string& id, const std::string& bankId, int targetS invalidatePanel(); } -// L7 Alt-REPLACE drop: the grabbed card `newId` takes the occupant `oldId`'s slot; `oldId` is -// removed from the bank's index (index-only, file untouched — pool guard enforced in the pure -// BankBook::replaceSample). Rejected (pool guard / absent) = a true NO-OP: no fallback insert, -// no undo point (per spec). One drop = one Ctrl-Z on success. +// Alt-REPLACE drop: the grabbed card `newId` takes the occupant `oldId`'s slot; `oldId` is +// removed from the bank's index (index-only, file untouched — pool guard enforced in the +// pure BankBook::replaceSample). Rejected = a true NO-OP: no fallback insert, no undo point. void doReplaceDrop(const std::string& newId, const std::string& oldId, const std::string& bankId) { if (!book() || newId.empty() || oldId.empty() || bankId.empty()) return; @@ -409,24 +371,22 @@ void resetDragState() { } // Commits (or abandons) a drag on button-up. The resolved pure CardGesture decides: -// * Reorder / Replace -> in-grid, within the source bank (L7); one Ctrl-Z each. -// * Move / Copy -> the EXISTING cross-bank transfer (unchanged; Ctrl = copy). +// * Reorder / Replace -> in-grid, within the source bank; one Ctrl-Z each. +// * Move / Copy -> the cross-bank transfer (Ctrl = copy). // * None -> a drop over dead space / the source-bank gap = no-op. // OsDragOut is never seen here: the pointer-left-client handoff happens live in onMouseMove. void onLBtnUp(int x, int y) { if (g_panel.dragging) { - // S17 drop-and-load: a release while hover-tracking a valid FX hotspot instantiates a + // Drop-and-load: a release while hover-tracking a valid FX hotspot instantiates a // ReaSampler 9000 on that track preloaded with the dragged capture — NOT a bank move, - // NOT an OS drag, NEVER a timeline insert. Takes priority over the L7 in-grid / cross-bank - // drop (the pointer is out over a track, not over a bank region). Single-capture only (the - // gesture never armed for a multi payload), so dragSampleIds.front() is the capture. + // NOT an OS drag, NEVER a timeline insert. Takes priority over the in-grid / cross-bank + // drop. Single-capture only, so dragSampleIds.front() is the capture. if (g_panel.instrumentDropTrack && g_panel.dragSampleIds.size() == 1) { const std::string sampleId = g_panel.dragSampleIds.front(); performInstrumentDrop(g_panel.instrumentDropTrack, buildInstrumentDropPreset(sampleId)); - // Read-only over the bank + arrange: the ONLY mutations are the new FX instance + - // its state (both undoable in performInstrumentDrop). No book change, no ext-state, - // no dirty-mark here. + // Read-only over the bank + arrange: the only mutations are the new FX instance + + // its state (both undoable in performInstrumentDrop). } else { updateDropTarget(x, y); classifyCardDrag(x, y); // re-resolve at the drop point (modifiers may have changed) @@ -481,7 +441,6 @@ void handleRightClick(int x, int y) { GetClientRect(g_panel.hwnd, &cr); const int w = cr.right - cr.left, h = cr.bottom - cr.top; - // Tab management menu. if (banksShown()) { const RECT br = banksRegionRect(w, h); const TabStripRect strip = banksTabStripRect(br); diff --git a/src/shell/panel/panel_input.cpp b/src/shell/panel/panel_input.cpp index 8a2a1b4..2fbdf91 100644 --- a/src/shell/panel/panel_input.cpp +++ b/src/shell/panel/panel_input.cpp @@ -1,13 +1,9 @@ -// panel_input.cpp — the input + detection seam of the docked bank panel (Q-W2 split -// of bank_panel.cpp). Owns left-click / wheel / keyboard routing (plain free-function -// calls on the per-event path — T4-28), the accelerator registration, the tail-setting -// read/mutate helpers, and the timer-driven new-content auto-tag detection (D2 Wave 2). -// Mouse-MOVE (hover + the card-drag state machine) lives in panel_drag; the bank-change +// panel_input.cpp — the input + detection seam of the docked bank panel: left-click / +// wheel / keyboard routing, accelerator registration, tail-setting read/mutate helpers, +// and timer-driven new-content auto-tag detection. Mouse-MOVE lives in panel_drag; the // fingerprint pass lives in panel_thumbnails (it owns the cache it invalidates). -// -// Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.h -// WITHOUT REAPERAPI_IMPLEMENT — main.cpp owns the API pointers; here they are -// extern (CLAUDE.md §contract). DAW-verified, not unit tested. +// Compiled into the reaper_reasampler MODULE, without REAPERAPI_IMPLEMENT (main.cpp +// owns the API pointers). DAW-verified, not unit-tested. #include #include @@ -17,15 +13,15 @@ #include "shell/panel/panel_state.h" #include "shell/panel/panel_input.h" -#include "shell/actions/bank_actions.h" // bankPruneCommandId — the footer Prune dispatch (R3) +#include "shell/actions/bank_actions.h" // bankPruneCommandId — the footer Prune dispatch #include "shell/persist/session.h" // ReaSamplerSession — view/tail reads + mutation -#include "core/view/view_mode_model.h" // autoTagNewContent / NewItem / AutoTag (D2 Wave 2) -#include "shell/capture/item_read.h" // itemGuid / itemLaneName — shared item-read seam (D2 W3-B) -#include "shell/capture/track_guid.h" // guidString — canonical track GUID key (D2 Wave 2) -#include "shell/view/view.h" // applyMode / mintManagedLanes — mode activation (D2/D4) +#include "core/view/view_mode_model.h" // autoTagNewContent / NewItem / AutoTag +#include "shell/capture/item_read.h" // itemGuid / itemLaneName — shared item-read seam +#include "shell/capture/track_guid.h" // guidString — canonical track GUID key +#include "shell/view/view.h" // applyMode / mintManagedLanes — mode activation -// 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. +// New-content detection: enumerate live tracks + items and read fixed-lane state to +// classify an item's lane as managed vs manual. #define REAPERAPI_MINIMAL #define REAPERAPI_WANT_CountTracks #define REAPERAPI_WANT_GetTrack @@ -50,23 +46,17 @@ TailSetting currentTail() { namespace { -// 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. +// Commits the current tail setting to ext state and marks the active project dirty so the +// change travels inside the .rpp on Ctrl+S — closes the gap where toggle/scroll would dirty +// the project but never write the new value. No-ops cleanly on an unsaved project. void markTailDirty() { if (g_panel.session) g_panel.session->saveToActiveProject(); ReaProject* proj = EnumProjects(-1, nullptr, 0); if (proj) MarkProjectDirty(proj); } -// Routes a click in a toolbar to the hit button's action, fired through the command-id contract -// (Main_OnCommand — REAPER runs the SAME action a keybinding would). Returns true iff the click -// was inside the bar band (handled, or a harmless gap/overflow/unregistered no-op), so the -// caller stops before grid handling. `rows` is the toolbar's inventory. +// Routes a click in a toolbar to the hit button's action via Main_OnCommand. Returns true +// iff the click was inside the bar band, so the caller stops before grid handling. bool handleToolbarClick(int x, int y, const ActionBarRect& bar, const std::vector& rows) { if (bar.height <= 0) return false; @@ -78,46 +68,26 @@ bool handleToolbarClick(int x, int y, const ActionBarRect& bar, x >= bar.x && x < bar.x + bar.width; } const ActionBarRow& row = rows[static_cast(hit)]; - // A disabled button (L5 opposite-mode gate) is claimed but no-ops — the click never fires the - // action and never falls through to the grid (a dead button reads as inert, not absent). + // A disabled button (opposite-mode gate) is claimed but no-ops — the click never fires + // the action and never falls through to the grid (a dead button reads as inert, not absent). if (!row.enabled) return true; const int cmd = resolveBarCommandId(row); if (cmd != 0 && Main_OnCommand) Main_OnCommand(cmd, 0); return true; } -// --- New-content detection (D2 Wave 2) ---------------------------------------- -// -// REAPER exposes no "item/track added" callback, so we diff live project state on the -// existing timer. Each tick: enumerate every track GUID and every item GUID, diff -// against the previous tick (GuidBaseline, first-poll-guarded), and auto-tag the new -// GUIDs into the active mode via the pure autoTagNewContent. An item on a MANUAL lane -// is exempt (design point #1) — its lane's durable name lacks the managed prefix. All -// enumeration is READ-ONLY on the project; the only mutation is to the in-memory -// membership index (persisted by persist on the next save, same as an action-driven tag). - -// 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 -// panel_input.cpp stays self-contained without pulling in view.cpp's private namespace. +// True iff `tr` has I_FREEMODE==2 (fixed lanes enabled). Value verified in view.cpp; +// reproduced locally so this file stays self-contained. constexpr int kFreeModeFixedLanes = 2; bool isFixedLaneTrack(MediaTrack* tr) { return static_cast(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). panel_input.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. -// `trackItemGuids` additionally maps each track GUID to the item GUIDs it carries, so a -// newly-detected item's PRE-EXISTING siblings can be resolved (the adoption / strand -// guard) 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. +// Enumerates the live project's track + item GUIDs. Fills `allGuids` and, per item, +// whether it sits on a manual lane (exempt from auto-tag). `trackItemGuids` maps each +// track to its item GUIDs so a newly-detected item's PRE-EXISTING siblings resolve in +// one lookup. Manual-lane classification uses the single pure predicate isOnManualLane. void enumerateLiveGuids(ReaProject* proj, std::set& allGuids, std::map& itemOnManualLane, std::map>& trackItemGuids) { @@ -140,9 +110,8 @@ void enumerateLiveGuids(ReaProject* proj, std::set& allGuids, 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). + // "" for a normal track — isOnManualLane returns false immediately for a + // non-fixed-lane track regardless of name. const std::string ln = fixedLane ? itemLaneName(tr, it) : std::string{}; itemOnManualLane[ig] = isOnManualLane(fixedLane, ln); itemsOnTrack.push_back(ig); @@ -150,35 +119,26 @@ void enumerateLiveGuids(ReaProject* proj, std::set& allGuids, } } -// 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. +// One detection tick: REAPER exposes no "item/track added" callback, so this diffs live +// GUIDs against the baseline and auto-tags the new ones into the active mode. Runs every +// timer tick regardless of panel open/close. READ-ONLY on the project; mutates only the +// in-memory membership index — deliberately OUTSIDE any Undo block (auto-tag is a +// background metadata update, not a destructive edit; an Undo block here would flood +// REAPER's history with an entry per tick that sees new content). // -// INTENTIONAL: membership mutation happens OUTSIDE any Undo block. Auto-tag is a -// background metadata update (like setting a label), not a destructive project edit. The -// persist shell (ext_state_io.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. +// Returns true iff this tick tagged at least one new GUID — the signal the caller uses to +// decide whether to run the lane-minting pass. 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. + // projects. bankPanelNotifyProjectLoaded() sets reloadPending on the tick persist + // restores membership + active mode; draining it here re-baselines against the + // fully-loaded set, so pre-existing untagged tracks stay Arrange rather than getting + // mass-tagged. Using persist's load signal (not a local ReaProject* compare) is what + // fixes the reload-mis-tag bug: pointer identity can recycle across projects. if (g_panel.reloadPending) { g_panel.contentBaseline.reset(); g_panel.reloadPending = false; @@ -210,12 +170,9 @@ bool detectNewContent() { for (const auto& [trackGuid, items] : trackItemGuids) for (const std::string& ig : items) trackOfItem[ig] = trackGuid; - // The distinct modes the PRE-EXISTING (not-new-this-tick) MANAGED-ELIGIBLE items on - // `trackGuid` resolve to. Untagged siblings resolve to Arrange (leafBelongsToMode's - // default); new siblings are excluded; manual-lane siblings are EXEMPT — exactly as - // planLaneMinting ignores them when computing a track's own-item mode span, so the - // adoption guard's view of the track matches the split decision's. Drives the adoption - // / strand guard in autoTagNewContent. + // The distinct modes the PRE-EXISTING, managed-eligible items on `trackGuid` resolve + // to. Untagged siblings default to Arrange; new siblings excluded; manual-lane + // siblings EXEMPT — matching planLaneMinting's own-item mode span computation. const auto preExistingTrackModes = [&](const std::string& trackGuid) -> std::set { std::set modes; @@ -233,8 +190,8 @@ bool detectNewContent() { }; // 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). + // manual-lane exemption to items only. A GUID in the item-lane map is an item; + // otherwise it's a track. std::vector newTracks; std::vector newItems; for (const std::string& g : added) { @@ -256,28 +213,21 @@ bool detectNewContent() { return !tags.empty(); } -// The item count the SELECTION reasons over — the focused region's occupied-cell count. -// L7: selection/navigation traverse OCCUPIED cells only (empty slots are gaps, not -// selectable). Occupied count == index size by construction: every index member maps to -// exactly one occupied slot (gaps are empty slots, which the index never backs), so the -// raw index size IS the dense selection-space extent. +// The item count the SELECTION reasons over. Occupied count == index size by +// construction, so the raw index size IS the dense selection-space extent. int focusedItemCount() { const BankModel* idx = indexForRegion(g_panel.focusedRegion); return idx ? static_cast(idx->size()) : 0; } -// --- Click routing ------------------------------------------------------------ - // Handles a header/tab-strip/button click for the banks region. Returns true if the // click was consumed (a region-chrome hit), false to fall through to grid selection. bool handleBanksChromeClick(int x, int y, const RECT& region) { - // Full-height toggle button. const RECT ftb = fullHtBtnRect(region); if (x >= ftb.left && x < ftb.right && y >= ftb.top && y < ftb.bottom) { bankPanelToggledBanksFullHeight(); return true; } - // "+" create button. const RECT cb = createBtnRect(region); if (x >= cb.left && x < cb.right && y >= cb.top && y < cb.bottom) { doCreateBank(); @@ -326,16 +276,15 @@ bool handlePoolChromeClick(int x, int y, const RECT& region) { // Applies a left-click at (x, y): route to top toolbar / footer (toggle / Tail / Prune) / // bottom toolbar / region chrome / grid selection, and arm a potential drag when the click -// lands on a selected cell. L4 order mirrors the three-zone layout top-to-bottom. +// lands on a selected cell. Order mirrors the three-zone layout top-to-bottom. void handleClick(int x, int y) { RECT cr{}; GetClientRect(g_panel.hwnd, &cr); const int w = cr.right - cr.left, h = cr.bottom - cr.top; - // TOP toolbar: the far-right More button first (its rect sits in the band's reserved right - // strip, outside the action rect), then the frequent capture/placement buttons. A button - // fires its registered action via the command-id contract; the band is claimed whole (a - // gap/overflow miss is a harmless no-op, never a fall-through). Capture never auto-inserts. + // TOP toolbar: the far-right More button first, then the frequent capture/placement + // buttons. A button fires its registered action via the command-id contract; the band + // is claimed whole (a gap/overflow miss is a harmless no-op, never a fall-through). { const MenuButtonRect mb = topMenuButtonRect(w); if (hitTestMenuButton(x, y, mb)) { showMoreMenu(); return; } @@ -345,10 +294,8 @@ void handleClick(int x, int y) { // the More button) so a click there is inert chrome, never a fall-through to the grid. if (y >= 0 && y < kTopToolbarHeight && x >= 0 && x < w) return; - // Footer: mode toggle (left) -> Tail button -> Prune (right). The narrow [Arrange|Design] - // toggle activates that mode; the Tail button cycles the tail setting (L4 §4 — was a - // click-zone); Prune fires the guarded prune command. Checked before the bottom toolbar / - // grid so a footer click never selects a cell. + // Footer: mode toggle (left) -> Tail button -> Prune (right). Checked before the bottom + // toolbar / grid so a footer click never selects a cell. { const int seg = footerToggleSegmentHit(x, y, w, h); if (seg >= 0) { @@ -363,9 +310,7 @@ void handleClick(int x, int y) { const FooterBarLayout fb = footerBarLayoutFor(w, h); if (g_panel.session && hitTestFooterBar(x, y, fb) == FooterHit::Tail) { - // Tail button click cycles the tail mode (None -> Auto -> Manual -> None). Mutates - // the SESSION's tail setting (capture reads it; persist saves it with the project) - // and marks the project dirty — touches NOTHING in the bank/arrange. + // Cycles None -> Auto -> Manual -> None; touches NOTHING in the bank/arrange. TailSetting& tail = g_panel.session->tail(); tail.mode = cycleTailMode(tail.mode); markTailDirty(); @@ -373,10 +318,9 @@ void handleClick(int x, int y) { return; } - // Prune button (R3): fires the "Prune bank folder" action THROUGH its registered - // command id (fork R-E: dispatch the command, not the session directly) so the panel - // affordance and the bindable action share the one guarded dry-run/confirm/delete path - // in doBankPruneFolder. A 0 id (pre-registration) no-ops. + // Fires through its registered command id (not the session directly) so the panel + // affordance and the bindable action share the one guarded dry-run/confirm/delete + // path in doBankPruneFolder. const ButtonRect pb = pruneButtonRectFor(w, h); if (hitTestPruneButton(x, y, pb)) { const int cmd = bankPruneCommandId(); @@ -385,11 +329,8 @@ void handleClick(int x, int y) { } } - // BOTTOM toolbar (Design-View verbs): a button fires its registered action via the - // command-id contract. Claimed whole like the top toolbar. if (handleToolbarClick(x, y, bottomToolbarRect(w, h), bottomBarRows())) return; - // Region chrome (headers, tab strip, buttons). if (poolShown()) { const RECT pr = poolRegionRect(w, h); if (y >= pr.top && y < regionGridRect(pr, false).top) { @@ -403,15 +344,13 @@ void handleClick(int x, int y) { } } - // Grid selection. Resolve which region's grid the point is in. Region reg = Region::Pool; if (!regionAt(x, y, reg)) return; const bool isBanks = reg == Region::Banks; const RECT region = isBanks ? banksRegionRect(w, h) : poolRegionRect(w, h); - // L7: hit-test the SPARSE slot layout, then map the slot to a selection ordinal. An - // empty (gap) slot hits selectionForSlot == -1, which reads as a grid miss below (a - // click on a gap clears selection, exactly like a click in the margin) — empty slots - // are decorative, not selectable. + // Hit-test the SPARSE slot layout, then map the slot to a selection ordinal. An empty + // (gap) slot hits selectionForSlot == -1, which reads as a grid miss below (a click on + // a gap clears selection, exactly like a click in the margin). const RegionDisplay disp = regionDisplay(region, isBanks, reg); const int hitSlot = hitTestSlot(x, y, disp.slotRects); const int hit = hitSlot < 0 ? -1 : disp.selectionForSlot(hitSlot); @@ -434,19 +373,12 @@ void handleClick(int x, int y) { } // Drag-arm disambiguation for plain (no ctrl, no shift) presses on a grid cell: - // - // • Already-selected cell: defer the selection change to LBUTTONUP so a plain - // press on a multi-selection doesn't collapse it before we know whether a drag - // will happen. Arm the drag with the current (multi-)selection as the payload - // candidate; only the caret moves immediately. - // - // • Unselected cell: apply the plain-click selection immediately (collapses to - // the single pressed cell) THEN arm a drag from it — so the user can press-and- - // drag in one gesture without a prior selecting click. The selection is set - // before arming so that focusedSelectionIds() resolves the right payload when - // the threshold is crossed in onMouseMove. - // - // ctrl / shift presses are selection-only gestures — no drag arm in either case. + // already-selected cell defers the selection change to LBUTTONUP (so a plain press on + // a multi-selection doesn't collapse it before we know whether a drag will happen; only + // the caret moves immediately); unselected cell applies the plain-click selection now + // (collapses to the single pressed cell) so a press-and-drag works without a prior + // selecting click and focusedSelectionIds() resolves the right payload once the + // threshold is crossed. ctrl/shift presses are selection-only — no drag arm. const bool onSelected = g_panel.selection.contains(hit); if (!ctrlDown() && !shiftDown()) { if (!onSelected) { @@ -461,12 +393,10 @@ void handleClick(int x, int y) { g_panel.dragStartX = x; g_panel.dragStartY = y; g_panel.dragSourceRegion = reg; - // Capture the mouse NOW so WM_MOUSEMOVE is delivered even when the pointer leaves the - // panel client rect before the drag threshold is crossed. Without capture, outside moves - // are not delivered, so a fast straight-out drag never transitions dragArmed → dragging - // and the OS drag-out never fires on the first pass. The capture is released on button-up - // (no drag: onLBtnUp dragArmed branch; drag: OsDrag path or onLBtnUp dragging branch) - // and on WM_CAPTURECHANGED (stolen or external release — already calls resetDragState). + // Capture the mouse NOW so WM_MOUSEMOVE is still delivered once the pointer leaves the + // client rect before the drag threshold is crossed — without capture, a fast + // straight-out drag never transitions dragArmed -> dragging. Released on button-up or + // WM_CAPTURECHANGED (which already calls resetDragState). SetCapture(g_panel.hwnd); invalidatePanel(); return; @@ -477,13 +407,10 @@ 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. +// 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]. Otherwise does nothing (returns false so the caller can let REAPER/the +// docker handle the wheel normally). 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; @@ -542,8 +469,7 @@ bool handleKey(int vk) { stopAudition(); return true; case VK_DELETE: { - // Remove the focused-region selection (B5). Silent; a no-op when nothing - // is selected. + // Remove the focused-region selection. Silent; a no-op when nothing is selected. const std::vector sel = focusedSelectionIds(); if (sel.empty()) return false; // nothing selected — let the key fall through removeSamples(sel, bankIdForRegion(g_panel.focusedRegion)); @@ -580,35 +506,27 @@ void unregisterAccel() { } // namespace reasampler::panel -// --- Public API (the timer + tail read seam — panel_input.h) ------------------- - namespace reasampler { 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. + // Arms the new-content detector to re-baseline on its next tick so the just-loaded + // project's pre-existing content is the baseline (nothing new) rather than diffed + // against the previous project and mass-tagged. A flag, not an inline reset, because + // detectNewContent owns the baseline and runs later in the SAME OnTimer tick. panel::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). + // tracks/items are created in the arrange view, not the panel. READ-ONLY on the + // project; only mutates the in-memory membership index. const bool tagged = panel::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 + // Lane minting 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. + // block and only mints for tracks that hold >1 mode's content. Managed lanes only. if (tagged && panel::g_panel.session) { ReaProject* proj = EnumProjects(-1, nullptr, 0); mintManagedLanes(panel::g_panel.session->view(), proj); @@ -616,8 +534,8 @@ void bankPanelRefresh() { if (!panel::g_panel.open || !panel::g_panel.hwnd) return; - // L5: the custom hover-delay tooltip is driven off this poll tick (no dedicated timer) — if a - // toolbar button has rested under the pointer past the delay, latch + repaint the tooltip. + // The custom hover-delay tooltip is driven off this poll tick (no dedicated timer) — if + // a toolbar button has rested under the pointer past the delay, latch + repaint it. panel::maybeShowTooltip(); if (panel::refreshFingerprint()) @@ -625,10 +543,9 @@ void bankPanelRefresh() { } capture::TailSetting bankPanelTailSetting() { - // 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. + // The authoritative setting lives in the session so it travels inside the .rpp; this + // is 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. capture::TailSetting s = panel::currentTail(); s.manualMs = capture::clampManualMs(s.manualMs); return s; diff --git a/src/shell/panel/panel_input.h b/src/shell/panel/panel_input.h index 9f08280..efb3abd 100644 --- a/src/shell/panel/panel_input.h +++ b/src/shell/panel/panel_input.h @@ -1,43 +1,24 @@ #pragma once -// panel_input — the input + detection seam of the bank panel (Q-W2 split of -// bank_panel.h). The .cpp owns mouse-click / wheel / keyboard routing (plain -// free-function calls per T4-28 — no interface on the per-event path) plus the -// timer-driven detection passes: new-content auto-tag (D2 Wave 2) and the -// hover-delay tooltip latch. This header carries the timer/lifecycle surface -// main.cpp drives and the tail-setting read seam the capture actions consume. -// -// REAPER-free as practical: TailSetting is the pure capture-side type. +// panel_input — input + detection seam of the bank panel: mouse/wheel/keyboard +// routing plus timer-driven passes (new-content auto-tag, tooltip hover-delay +// latch). REAPER-free as practical: TailSetting is the pure capture-side type. -#include "core/capture/tail_control.h" // capture::TailSetting — the panel's tail-mode toggle state +#include "core/capture/tail_control.h" namespace reasampler { -// Requests a repaint if the bank changed since the last paint (generation bump). -// Cheap when nothing changed. Driven by the timer so a capture / project load is -// reflected without the panel diffing the bank itself. Also hosts the every-tick -// new-content auto-tag detection (runs whether or not the dock is visible) and the -// tooltip hover-delay latch. +// Repaints if the bank changed since last paint (generation bump); cheap no-op +// otherwise. Also drives the auto-tag detector and tooltip hover latch each tick. 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. +// Call on the exact tick persist's project-load signal drains, before reapplying the +// active mode. Re-arms the new-content detector so the just-loaded project's existing +// content is the baseline, not diffed against the prior project and mass-tagged. 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 -// capture with no explicit choice stays byte-identical to today. The authoritative -// setting lives in ReaSamplerSession (it travels inside the .rpp); the panel mutates -// it via the footer Tail button (cycle) and scroll-wheel (Manual fine-adjust), both -// owned by this input seam. Safe to call before the panel has ever opened (returns -// the default). READ of panel state only. +// Read by CAPTURE_ITEM/CAPTURE_TRACK to apply the panel's tail toggle to a +// CaptureRequest. Default None (exact bounds, byte-identical to no tail). Safe +// before the panel has ever opened. capture::TailSetting bankPanelTailSetting(); } // namespace reasampler diff --git a/src/shell/panel/panel_layout.cpp b/src/shell/panel/panel_layout.cpp index 34d9311..1a3de78 100644 --- a/src/shell/panel/panel_layout.cpp +++ b/src/shell/panel/panel_layout.cpp @@ -1,18 +1,11 @@ -// panel_layout.cpp — the geometry-glue seam of the docked bank panel (Q-W2 split of -// bank_panel.cpp; the T4-01 NEW seam). Owns the toolbar/footer/menu rects, the -// toolbar row/cluster builders, the vertical-split geometry + region rects, and the -// L7 slot-order display bridge (regionDisplay/focusedDisplay). Every rect is derived -// from the client size + fullHeight state, and BOTH paint (panel_render) and -// hit-testing (panel_input / panel_drag) call these so they never drift. +// panel_layout.cpp — geometry-glue seam of the docked bank panel: toolbar/footer/menu +// rects, toolbar row/cluster builders, vertical-split geometry + region rects, and +// the slot-order display bridge. Every rect is derived from client size + fullHeight +// state, and both paint and hit-testing call these so they never drift. Also home of +// the public split-state seam (panel_layout.h). // -// Also home of the public split-state seam (panel_layout.h): the B3-owned -// BankPanelFullHeight toggles the render derives the region rects from. -// -// Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.h -// WITHOUT REAPERAPI_IMPLEMENT — main.cpp owns the API pointers; here they are -// extern (CLAUDE.md §contract). DAW-verified, not unit tested (the PURE tiling / -// hit-test math lives in action_bar / footer_bar / prune_button / overflow_menu / -// tab_strip / mode_switch / bank_grid / card_drag, unit-tested outside the DAW). +// main.cpp owns the API pointers; here they are extern. DAW-verified, not unit tested +// (the pure tiling/hit-test math lives in action_bar / footer_bar / etc.). #include #include @@ -20,12 +13,11 @@ #include "shell/panel/panel_state.h" #include "shell/panel/panel_layout.h" -#include "shell/persist/session.h" // ReaSamplerSession — mode/view reads -#include "core/view/view_mode_model.h" // ViewModeModel — modes()/activeModeId() +#include "shell/persist/session.h" +#include "core/view/view_mode_model.h" -// Action-trigger buttons (M11): resolve each button's command id at runtime from the -// composed named-command string and read its current key binding for the tooltip. -// All main-section (SectionFromUniqueID(0)). +// Resolves each button's command id at runtime from the composed named-command +// string, and reads its current key binding for the tooltip. Main-section only. #define REAPERAPI_MINIMAL #define REAPERAPI_WANT_NamedCommandLookup #define REAPERAPI_WANT_kbd_getTextFromCmd @@ -34,20 +26,14 @@ namespace reasampler::panel { -// --- Mode toggle (D5; relocated to the footer at L4) -------------------------- - int modeCount() { if (!g_panel.session) return 0; return static_cast(g_panel.session->view().modes().size()); } -// --- Top toolbar band (L4; L5 overflow-menu reserve) -------------------------- -// -// The TOP toolbar (capture + placement) occupies the very top of the client. Degenerate -// (height 0) when the client is too short to host it above the split body. The WHOLE band -// (topToolbarRect) is what the far-right More button anchors into; the action_bar's frequent -// buttons tile into the band MINUS the menu reserve (topToolbarActionRect), so they never run -// under the menu button (L5 refinement 1). +// topToolbarRect is what the far-right More button anchors into; the action_bar +// buttons tile into the band minus the menu reserve (topToolbarActionRect) so +// they never run under it. namespace { @@ -60,25 +46,21 @@ ActionBarRect topToolbarRect(int w) { return s; } -// The band the More button occupies (the whole top toolbar band as a MenuBarRect). MenuBarRect topMenuBarRect(int w) { const ActionBarRect bar = topToolbarRect(w); return MenuBarRect{bar.x, bar.y, bar.width, bar.height}; } -// The More button's rect (right-anchored in the top band). Empty when the band is too narrow -// to place it clear of its left inset — the three variants stay reachable via their bindable -// commands (graceful suppression). } // namespace +// Empty when the band is too narrow to place the button clear of its left +// inset — suppressed gracefully; still reachable via its bindable command. MenuButtonRect topMenuButtonRect(int w) { return computeMenuButton(topMenuBarRect(w), kMenuBtnSpec); } -// The rect the TOP toolbar's action_bar tiles into: the whole band MINUS the reserve for the -// far-right More button, so the frequent buttons never overlap it. When the More button is -// suppressed (band too narrow) the reserve is still subtracted (the reserve is 0 only for a -// degenerate band), which keeps draw and hit-test consistent whether or not the button shows. +// Reserve is still subtracted even when the button is suppressed (0 only for a +// degenerate band), keeping draw and hit-test consistent either way. ActionBarRect topToolbarActionRect(int w) { ActionBarRect bar = topToolbarRect(w); const int reserve = menuButtonReserve(topMenuBarRect(w), kMenuBtnSpec); @@ -87,8 +69,6 @@ ActionBarRect topToolbarActionRect(int w) { return bar; } -// --- Footer (L4) -------------------------------------------------------------- - RECT panelFooter(int w, int h) { RECT rc{}; rc.left = 0; @@ -100,8 +80,8 @@ RECT panelFooter(int w, int h) { return rc; } -// The footer LEFT-group layout (mode toggle + count + Tail button), derived from the client -// size. SINGLE source of truth for draw and hit-test. All-empty when the footer is degenerate. +// Left-group layout (mode toggle + count + Tail button). Single source of truth +// for draw and hit-test. FooterBarLayout footerBarLayoutFor(int w, int h) { const RECT f = panelFooter(w, h); if (f.top >= f.bottom) return FooterBarLayout{}; @@ -109,22 +89,18 @@ FooterBarLayout footerBarLayoutFor(int w, int h) { return computeFooterBar(footer, FooterBarSpec{}); } -// The prune button's rect within the footer, derived from the client size. SINGLE source -// of truth for both draw and hit-test (they never drift). Empty when the footer is degenerate -// or too narrow to place the button clear of the footer-left group / version readout — the -// action stays reachable via its bindable command, so a suppressed button is graceful. Kept -// set apart at the RIGHT (footer_bar reserves the matching space at its right so the two -// groups never overlap). See prune_button.h §Placement contract. +// Empty when the footer is degenerate or too narrow to clear the left group / +// version readout — reachable via its bindable command regardless. Set apart at +// the right (footer_bar reserves matching space so the groups never overlap). ButtonRect pruneButtonRectFor(int w, int h) { const RECT f = panelFooter(w, h); - if (f.top >= f.bottom) return ButtonRect{}; // degenerate footer -> no button + if (f.top >= f.bottom) return ButtonRect{}; const FooterRect footer{f.left, f.top, f.right - f.left, f.bottom - f.top}; return computePruneButton(footer, PruneButtonSpec{}); } -// True iff client-relative (x, y) falls inside the (non-degenerate) footer strip. Used by the -// scroll-wheel (Manual tail fine-adjust) so a wheel notch over the footer is claimed. The -// Tail-cycle CLICK no longer uses this — it now hits the Tail button rect (footer_bar). +// Used by the scroll-wheel (Manual tail fine-adjust); the Tail-cycle click hits +// the Tail button rect (footer_bar) instead. bool pointInFooter(int x, int y) { if (!g_panel.hwnd) return false; RECT cr{}; @@ -133,37 +109,11 @@ bool pointInFooter(int x, int y) { return f.top < f.bottom && x >= f.left && x < f.right && y >= f.top && y < f.bottom; } -// === Task-grouped toolbars (Phase L, L2 + L4) ================================= -// -// L4 re-homes the button inventory around frequency and intent (DS-3 layout, not a re-skin) -// across TWO toolbars, BOTH drawn through the pure action_bar module: -// * the TOP toolbar (Capture + Placement) sits at the very top where the eye lands — the -// two acts the tool exists for (L4 §1); -// * the BOTTOM toolbar (the Design-View verbs: Tagging then Switching) sits above the -// footer, in the space capture/placement vacated (L4 §2). -// Each button is drawn with its action name (Font::Label) and live key binding on a Micro -// sub-row (the L2 contract). action_bar owns the cluster tiling, the label/binding sub-rects, -// the whole-trailing-button overflow, and the hit-test; only the kit draw + SDK binding query -// + the NamedCommandLookup/Main_OnCommand dispatch live here. -// -// Each button resolves its command id at RUNTIME from the composed named-command string -// (NamedCommandLookup on "_" + channelCommandId(suffix)), so it is channel-correct on stable -// and beta and adds NO second registration. A cmd of 0 (action not registered on this channel) -// draws Disabled and no-ops on click. L4 is layout-only: the SAME existing actions fire via the -// SAME contract — no re-wiring, no command-id changes, and capture never auto-inserts. - -// The TOP toolbar inventory (L6 refinement): the FREQUENT acts only — Capture (item / track) -// then Re-capture (Maintenance, set between the two capture verbs and the placement verbs) then -// Placement (insert / insert-conform). The FOUR RARE variants (Batch Items / Batch Razor / -// Capture RT / Cancel RT) are ALL in the far-right "⋯" overflow menu (overflowMenuRows) — -// same registered actions, same command-id contract, just a different home. Capture scopes come -// from captureActionTable() (render_settings, pure); the rest are the registered M11/M10/M8 -// commands. Built once per draw/click. Each row carries its full (prefix-stripped) action name -// for the hover tooltip. +// Frequent acts only: Capture (item/track), Re-capture (between capture and +// placement), Placement (insert/insert-conform). The four rare variants live in +// the overflow menu (overflowMenuRows) — same actions, different home. std::vector topBarRows() { std::vector rows; - // Capture cluster — the primary gesture, leftmost. Face is a terse "Capture Item/Track"; - // the tooltip carries the full descriptionPhrase the action was registered with. for (const CaptureActionDef& def : captureActionTable()) { std::string label = def.commandSuffix; if (label == "CAPTURE_ITEM") label = "Capture Item"; @@ -171,12 +121,8 @@ std::vector topBarRows() { rows.push_back({def.commandSuffix, label, def.descriptionPhrase, ActionCluster::Capture, true}); } - // Maintenance cluster — Re-capture from source (M10), placed BETWEEN the capture group and - // the placement group so its position reads "refine the last capture before placing it". - // Cancel RT lives in the overflow menu (both realtime verbs share that home — L6). rows.push_back({"RECAPTURE_FROM_SOURCE", "Re-capture", "re-capture from source", ActionCluster::Maintenance, true}); - // Placement cluster — the second act (still a distinct on-demand act; no auto-insert). rows.push_back({"INSERT_SELECTED", "Insert", "insert selected sample at edit cursor", ActionCluster::Placement, true}); rows.push_back({"INSERT_SELECTED_CONFORM", "Insert Conform", @@ -185,12 +131,8 @@ std::vector topBarRows() { return rows; } -// The TOP-toolbar OVERFLOW menu inventory (L6): four items pulled off the visible bar into the -// far-right "⋯" menu button's popup — the three rare batch/realtime capture variants plus -// Cancel RT (both realtime verbs share the menu home). Each fires the SAME existing registered -// command id via the SAME NamedCommandLookup/Main_OnCommand contract — no action changes. The -// fullName is the popup entry text (the terse shortLabel is unused for menu items; the popup has -// room for the full name). Batch entries first, then the two realtime verbs. +// The four rare batch/realtime capture variants pulled off the visible bar, plus +// Cancel RT. fullName is the popup entry text (shortLabel is unused for menu items). std::vector overflowMenuRows() { return { {"CAPTURE_BATCH_ITEMS", "Batch Items", @@ -206,8 +148,6 @@ std::vector overflowMenuRows() { namespace { -// The active mode id the opposite-mode gate + footer toggle both read (ONE source of truth for -// "which mode is active"). Empty when no session (every button then falls to fail-open live). std::string activeModeIdOrEmpty() { if (!g_panel.session) return {}; return g_panel.session->view().activeModeId(); @@ -215,26 +155,16 @@ std::string activeModeIdOrEmpty() { } // namespace -// The BOTTOM toolbar inventory (L5 refinement 3): FOUR Item/Track x Arrange/Design tag buttons -// then a set-apart Show Both. The suffixes are the ACTUAL registered command-id strings from -// design_view_actions.cpp (VIEW_MOVE_ITEMS_ARRANGE / VIEW_MOVE_ITEMS_DESIGN for the item moves; -// VIEW_TAG_ARRANGE / VIEW_TAG_DESIGN for the track tags; VIEW_SHOW_BOTH) — grepped, not -// paraphrased. "…: Arrange" routes through the untag/arrange path (Arrange = absence of a tag). -// The Toggle + both Activate buttons are REMOVED (L5 refinement 4 / settled inventory): the -// footer [Arrange|Design] toggle owns mode switching. -// -// OPPOSITE-MODE ENABLEMENT (L5): a tag button is LIVE only for the OPPOSITE of the active mode -// (you tag into the mode you are not in). The pure mode_enable::tagButtonEnabled decides it from -// the active mode id; Show Both is unconditional (not a tag target). enabled=false rows draw -// Disabled and no-op on click. The Item/Track axis is display-only here — both the Item and the -// Track button for a target share the target's enablement. +// Four Item/Track x Arrange/Design tag buttons then a set-apart Show Both. A tag +// button is live only for the OPPOSITE of the active mode (tag into the mode +// you're not in) — decided by the pure mode_enable::tagButtonEnabled; disabled +// rows draw Disabled and no-op on click. Show Both is unconditional. std::vector bottomBarRows() { const std::string active = activeModeIdOrEmpty(); const bool arrangeLive = tagButtonEnabled(active, TagTarget::Arrange); const bool designLive = tagButtonEnabled(active, TagTarget::Design); std::vector rows; - // Tagging cluster — the four Item/Track x Arrange/Design tag buttons. rows.push_back({"VIEW_MOVE_ITEMS_ARRANGE", "Item: Arrange", "move selected items -> Arrange", ActionCluster::Tagging, arrangeLive}); rows.push_back({"VIEW_MOVE_ITEMS_DESIGN", "Item: Design", @@ -243,18 +173,14 @@ std::vector bottomBarRows() { "tag selected tracks -> Arrange", ActionCluster::Tagging, arrangeLive}); rows.push_back({"VIEW_TAG_DESIGN", "Track: Design", "tag selected tracks -> Design", ActionCluster::Tagging, designLive}); - // Switching cluster — Show Both, set apart (the only survivor of the old switching group). rows.push_back({"VIEW_SHOW_BOTH", "Show Both", "show both for selected tracks", ActionCluster::Switching, true}); return rows; } -// The cluster button-count specs for a given row set, in the row list's cluster order (so the -// pure action_bar's flat index lines up with the row list). Handles all five cluster kinds; -// empty clusters contribute a 0-count spec (action_bar skips them, emitting no gap). The spec -// order follows each toolbar's fixed layout order (top: Capture, Maintenance, Placement — -// Re-capture sits between the two capture verbs and the placement verbs; bottom: Tagging, -// Switching). The bottom bar's Maintenance count is 0, so the order change is transparent there. +// Cluster button-count specs in the row list's cluster order, so action_bar's +// flat index lines up with the row list. Empty clusters contribute a 0-count +// spec (action_bar skips them, no gap). std::vector actionBarClusters(const std::vector& rows) { int nCap = 0, nPlace = 0, nMaint = 0, nTag = 0, nSwitch = 0; for (const ActionBarRow& r : rows) { @@ -275,8 +201,8 @@ std::vector actionBarClusters(const std::vector& rows }; } -// The BOTTOM toolbar band: a fixed-height band directly above the footer (below the split -// body). Degenerate (height 0) when the client is too short to host it above the footer. +// Fixed-height band directly above the footer; degenerate (height 0) when too +// short to host it above the footer. ActionBarRect bottomToolbarRect(int w, int h) { ActionBarRect s; const RECT footer = panelFooter(w, h); @@ -285,12 +211,10 @@ ActionBarRect bottomToolbarRect(int w, int h) { s.width = w; s.height = kBottomToolbarHeight; s.y = footerTop - kBottomToolbarHeight; - // Keep the bar below the top toolbar; if the client is too short, collapse it. if (s.y < kTopToolbarHeight) { s.y = footerTop; s.height = 0; } return s; } -// Resolves a row's composed named command to its runtime command id (0 if not registered). // The named-command lookup string is "_" + the channel-qualified id (REAPER's convention). int resolveBarCommandId(const ActionBarRow& row) { if (!NamedCommandLookup) return 0; @@ -300,8 +224,7 @@ int resolveBarCommandId(const ActionBarRow& row) { namespace { -// The current key binding string for a command in the MAIN section, or "" (unbound / not -// registered). Queried via kbd_getTextFromCmd (SectionFromUniqueID(0)). +// "" when unbound or not registered. std::string barBindingText(int cmd) { if (cmd != 0 && kbd_getTextFromCmd && SectionFromUniqueID) { const char* t = kbd_getTextFromCmd(cmd, SectionFromUniqueID(0)); @@ -318,16 +241,9 @@ int toolbarHit(int x, int y, const ActionBarRect& bar, const std::vector rows; @@ -343,8 +259,8 @@ bool currentTooltip(int w, int h, std::string& textOut, int& ax, int& ay, int& a } if (hv.index < 0 || hv.index >= static_cast(rows.size())) return false; - // The hovered button's slot rect (the anchor). computeBarSlots is the same layout the draw + - // hit-test use, so the anchor matches the drawn button exactly. + // computeBarSlots is the same layout draw + hit-test use, so the anchor + // matches the drawn button exactly. const std::vector clusters = actionBarClusters(rows); const std::vector slots = computeBarSlots(bar, clusters, kBarSpec); const ActionBarSlot* slot = nullptr; @@ -352,11 +268,9 @@ bool currentTooltip(int w, int h, std::string& textOut, int& ax, int& ay, int& a if (s.index == hv.index) { slot = &s; break; } if (!slot) return false; - // The full name is stored already prefix-free, but strip defensively in case a source ever - // carries the "ReaSampler:" display prefix (the tooltip must never show it — L5 refinement 2). - // L6: the keybinding sub-row was removed from the button face, so the tooltip now carries - // both the name AND the binding (when bound) — e.g. "capture selected item — F5". When the - // action is unbound the tooltip shows only the name (no "(unbound)" noise in the tooltip). + // fullName is stored prefix-free; strip defensively in case a source ever + // carries the display prefix. Tooltip carries name + binding when bound + // (e.g. "capture selected item — F5"), name only when unbound. const std::string phrase = stripActionPrefix(rows[static_cast(hv.index)].fullName, actionDisplayPrefix()); const int cmd = resolveBarCommandId(rows[static_cast(hv.index)]); @@ -366,14 +280,9 @@ bool currentTooltip(int w, int h, std::string& textOut, int& ax, int& ay, int& a return true; } -// --- Split geometry ----------------------------------------------------------- -// -// Every rect below is derived from the client size + fullHeight state, and BOTH paint -// and hit-testing call these so they never drift. All are top-left origin. - -// The body band between the TOP toolbar and the BOTTOM toolbar (L4). Its top edge is below the -// top toolbar; its bottom edge is the bottom toolbar's top. When the bottom bar collapses on a -// short client, bottomToolbarRect returns its y at the footer top, so the body still ends there. +// Between the top and bottom toolbars. When the bottom bar collapses on a short +// client, bottomToolbarRect returns its y at the footer top, so the body still +// ends there. RECT splitBody(int w, int h) { RECT rc{}; rc.left = 0; @@ -385,24 +294,20 @@ RECT splitBody(int w, int h) { return rc; } -// True when both regions are shown (the split is live). Otherwise one region fills -// the body. bool poolShown() { return g_panel.fullHeight != BankPanelFullHeight::BanksOnly; } bool banksShown() { return g_panel.fullHeight != BankPanelFullHeight::PoolOnly; } -// The pool region's rect (whole-region: header band + grid). Empty when hidden. +// Empty when hidden. RECT poolRegionRect(int w, int h) { const RECT body = splitBody(w, h); if (!poolShown()) return RECT{0, 0, 0, 0}; - if (!banksShown()) return body; // pool full-height: the whole body - // Split: pool gets the top half (minus the divider). + if (!banksShown()) return body; RECT rc = body; rc.bottom = body.top + (body.bottom - body.top - kSplitDividerHeight) / 2; if (rc.bottom < rc.top) rc.bottom = rc.top; return rc; } -// The named-banks region's rect (whole-region: header band + tab strip + grid). RECT banksRegionRect(int w, int h) { const RECT body = splitBody(w, h); if (!banksShown()) return RECT{0, 0, 0, 0}; @@ -414,7 +319,6 @@ RECT banksRegionRect(int w, int h) { return rc; } -// A region's header band (the top kRegionHeaderHeight of the region). RECT regionHeaderRect(const RECT& region) { RECT rc = region; rc.bottom = region.top + kRegionHeaderHeight; @@ -422,7 +326,6 @@ RECT regionHeaderRect(const RECT& region) { return rc; } -// The named-banks region's tab strip (below its header band). TabStripRect banksTabStripRect(const RECT& region) { const RECT hdr = regionHeaderRect(region); TabStripRect s; @@ -445,7 +348,6 @@ RECT regionGridRect(const RECT& region, bool isBanks) { return rc; } -// The full-height toggle button rect inside a region header (right-aligned). RECT fullHtBtnRect(const RECT& region) { const RECT hdr = regionHeaderRect(region); RECT rc = hdr; @@ -456,8 +358,6 @@ RECT fullHtBtnRect(const RECT& region) { return rc; } -// The "+" create-bank button rect inside the named-banks region header (left of the -// full-height button). RECT createBtnRect(const RECT& region) { RECT ft = fullHtBtnRect(region); RECT rc = ft; @@ -466,10 +366,8 @@ RECT createBtnRect(const RECT& region) { return rc; } -// Resolves a region's display for the currently-shown bank. Empty (no bank / no width) -// yields an empty display. orderedSampleIds reconciles the bank's SlotMap against live -// membership, so a freshly-migrated or out-of-band-mutated bank always yields a complete -// order (trailing empties are trimmed by the model — maxSlot walks only live occupants). +// orderedSampleIds reconciles the bank's SlotMap against live membership, so a +// freshly-migrated or out-of-band-mutated bank always yields a complete order. RegionDisplay regionDisplay(const RECT& region, bool isBanks, Region reg) { RegionDisplay d; BankBook* b = book(); @@ -490,8 +388,6 @@ RegionDisplay regionDisplay(const RECT& region, bool isBanks, Region reg) { return d; } -// The FOCUSED region's display (the slot-order bridge for the region holding the live -// selection). Mirrors columnsForRegion's client read. RegionDisplay focusedDisplay() { RECT cr{}; GetClientRect(g_panel.hwnd, &cr); @@ -501,8 +397,6 @@ RegionDisplay focusedDisplay() { return regionDisplay(region, isBanks, g_panel.focusedRegion); } -// Which region (if any) contains client point (x, y); returns false via `out` set to -// Pool by default when the point is in neither region body. bool regionAt(int x, int y, Region& out) { RECT cr{}; GetClientRect(g_panel.hwnd, &cr); @@ -522,8 +416,6 @@ bool regionAt(int x, int y, Region& out) { return false; } -// The footer mode-toggle segment (Arrange|Design) under (x, y), or -1. Segments are tiled by -// mode_switch inside footer_bar's toggle box, so both draw and hit-test use the same box. int footerToggleSegmentHit(int x, int y, int w, int h) { if (!g_panel.session) return -1; const FooterBarLayout fb = footerBarLayoutFor(w, h); @@ -532,7 +424,6 @@ int footerToggleSegmentHit(int x, int y, int w, int h) { return hitTestSegment(x, y, th, modeCount()); } -// The column count for a region's current grid width (nav needs the layout's wrap). int columnsForRegion(Region reg) { RECT cr{}; GetClientRect(g_panel.hwnd, &cr); @@ -545,8 +436,6 @@ int columnsForRegion(Region reg) { } // namespace reasampler::panel -// --- Public API (the split-state seam — panel_layout.h) ------------------------ - namespace reasampler { BankPanelFullHeight bankPanelFullHeight() { diff --git a/src/shell/panel/panel_layout.h b/src/shell/panel/panel_layout.h index 72ac6ea..a244a0f 100644 --- a/src/shell/panel/panel_layout.h +++ b/src/shell/panel/panel_layout.h @@ -1,43 +1,25 @@ #pragma once -// panel_layout — the vertical-split layout STATE seam of the bank panel (Q-W2 split of -// bank_panel.h; Phase B3/B4). The panel window splits vertically — pool on top, -// named-banks region below — and two toggles collapse the split. This header carries -// that public state surface; the geometry derivation itself (toolbar/footer/menu rects, -// row/cluster builders, region rects, the L7 slot-order display bridge) is internal to -// panel_layout.cpp (see panel_state.h for the intra-panel seam). -// -// REAPER-free: main.cpp / bank_actions.cpp drive these through plain free functions. +// panel_layout — vertical-split layout state seam of the bank panel (pool on top, +// named-banks region below, two toggles collapse the split). Geometry derivation +// itself is internal to panel_layout.cpp. REAPER-free. namespace reasampler { -// The vertical-split full-height layout state (Phase B). The bank window splits -// vertically — pool on top, named-banks region below — and two toggles collapse the -// split: pool full-height (hide the named-banks region) and banks full-height (hide -// the pool). The two are mutually exclusive with the default (both regions shown), -// so one enum captures the whole state. -// -// This bit is B3-owned (the actions flip it); B4's panel RENDERS from it. It lives -// beside the tail setting — the other session-level view-layout bit the panel -// reads — NOT in the persisted ReaSamplerSession: it is a UI-layout preference, not -// project state, so it must not travel with the .rpp. In-memory for the extension's -// lifetime; resets to Split on unload. +// UI-layout preference, not project state — deliberately not persisted in +// ReaSamplerSession (must not travel with the .rpp). Resets to Split on unload. enum class BankPanelFullHeight { - Split, // default: pool region on top, named-banks region below - PoolOnly, // pool full-height — named-banks region hidden - BanksOnly, // banks full-height — pool region hidden + Split, + PoolOnly, + BanksOnly, }; -// The current full-height layout state (default Split). READ by B4's panel to decide -// which region(s) to draw. Safe before the panel has ever opened. +// Safe before the panel has ever opened. BankPanelFullHeight bankPanelFullHeight(); -// Toggles pool full-height: Split <-> PoolOnly. From PoolOnly returns to Split; from -// either other state (Split or BanksOnly) enters PoolOnly. Bound to the "pool -// full-height" action. Requests a repaint so an open panel reflects the change. +// Split <-> PoolOnly; from BanksOnly also enters PoolOnly. Requests a repaint. void bankPanelToggledPoolFullHeight(); -// Toggles banks full-height: Split <-> BanksOnly, symmetric to the pool toggle. -// Bound to the "banks full-height" action. Requests a repaint. +// Split <-> BanksOnly, symmetric to the pool toggle. void bankPanelToggledBanksFullHeight(); } // namespace reasampler diff --git a/src/shell/panel/panel_render.cpp b/src/shell/panel/panel_render.cpp index e111169..9a0bd3f 100644 --- a/src/shell/panel/panel_render.cpp +++ b/src/shell/panel/panel_render.cpp @@ -1,49 +1,35 @@ -// panel_render.cpp — the LICE draw seam of the docked bank panel (Q-W2 split of -// bank_panel.cpp; M5 Wave A/B + Phase B4 + Phase L). Owns WM_PAINT's full paint: -// the VERTICAL SPLIT (pool grid region on top, named-banks tab-page region below), -// the region headers + tab strip, the two task-grouped toolbars + More button, the -// footer (mode toggle + count + Tail + Prune), the hover-delay tooltip overlay, and -// the per-card thumbnail/metadata draw — everything through the L1 kit by palette -// role (draw_kit), double-buffered, BitBlt'd once. -// -// READ-ONLY: reads panel + session state; the input/drag seams mutate it. All rect -// derivation comes from panel_layout (the single source both draw and hit-test use). -// -// Compiled into the reaper_reasampler MODULE. No REAPER API functions are called -// here (LICE/Win32 only); REAPER SDK types arrive via panel_state.h. +// panel_render.cpp — LICE draw seam of the docked bank panel. Owns WM_PAINT's full +// paint: the vertical split, region headers + tab strip, the two toolbars + More +// button, the footer, the tooltip overlay, and per-card thumbnail/metadata draw — +// everything through the kit by palette role, double-buffered, BitBlt'd once. +// Read-only: reads panel + session state; input/drag seams mutate it. All rect +// derivation comes from panel_layout (the single source both draw and hit-test +// use). No REAPER API functions are called here. #include #include #include "shell/panel/panel_state.h" -#include "shell/panel/draw_kit.h" // kit text()/fillSurface/drawButton/drawWaveform (L1) -#include "shell/persist/session.h" // ReaSamplerSession — mode/view/tail reads -#include "core/view/view_mode_model.h" // ViewModeModel / Mode — the footer toggle's model +#include "shell/panel/draw_kit.h" +#include "shell/persist/session.h" +#include "core/view/view_mode_model.h" namespace reasampler::panel { namespace { -// --- Drawing: thumbnails (via the kit's shared drawWaveform since FA3) --------- - -// Draws the L7 decorative metadata overlay on a card: bars.beats.subdivisions bottom-LEFT -// (musical, from the capture-time tempo + meter stamp) and seconds.milliseconds bottom-RIGHT -// (wall-clock). Decorative + non-interactive (no hit-test, no hover). Drawn in the kit's -// Micro / ValueMono classes in text/dim, subordinate to the waveform. A blank musical -// read-out (unstamped meter / unknown tempo) simply omits the bottom-left string. +// Bars.beats.subdivisions bottom-left (musical), seconds.milliseconds bottom-right. +// Decorative, non-interactive; a blank musical readout omits the left string. void drawCardMeta(LICE_IBitmap* bmp, const CellRect& rect, const Sample& s) { MusicalLength ml; ml.lengthSeconds = s.lengthSeconds; ml.tempoBpm = s.captureTempo; ml.timeSigNum = s.captureTimeSigNum; ml.timeSigDenom = s.captureTimeSigDenom; - const std::string bars = formatBarsBeats(ml); // "" when unstamped/no-tempo + const std::string bars = formatBarsBeats(ml); const std::string secs = formatSecondsMs(s.lengthSeconds); - // A short strip along the card's bottom edge. Left/right halves; text/dim so the - // waveform stays the centerpiece. Micro on the left (musical), ValueMono on the right - // (tabular numbers that must not jitter). const int stripH = 12; const int pad = 3; const int y = rect.y + rect.height - stripH; @@ -57,17 +43,13 @@ void drawCardMeta(LICE_IBitmap* bmp, const CellRect& rect, const Sample& s) { void drawThumbnail(LICE_IBitmap* bmp, const CellRect& rect, const Envelope& env, bool selected, bool focused, bool hovered, const Sample* sample) { - // Cell surface through the kit (L7 selection restyle): a selected card draws the NORMAL - // cell surface (Rest, or Hover when hovered) — NOT the inverted accent-fill. Selection is - // marked purely by an accent/tertiary (pastel purple) border below; hover stays a fill- - // state change orthogonal to that border, so a hovered selected card still reads selected. + // Selected cards draw the normal cell surface, not an inverted fill — selection + // is marked purely by the border below, kept orthogonal to hover state. const KitBox cell{rect.x, rect.y, rect.width, rect.height}; const InteractionState state = hovered ? InteractionState::Hover : InteractionState::Rest; fillSurface(bmp, cell, Role::BgCell, state); - // Border (L7): accent/TERTIARY purple when selected (the sole selection signal), else - // hairline. Focus is a distinct text/primary inner ring so a focused-AND-selected card - // reads BOTH — the purple outer border + the inner focus ring — kept visually separate. + // Focus is a distinct inner ring so a focused-AND-selected card reads both. const KitColor border = selected ? roleColor(Role::AccentTertiary) : roleColor(Role::LineHairline); LICE_DrawRect(bmp, rect.x, rect.y, rect.width, rect.height, toLice(border), 1.0f, 0); if (focused) { @@ -75,44 +57,22 @@ void drawThumbnail(LICE_IBitmap* bmp, const CellRect& rect, const Envelope& env, LICE_DrawRect(bmp, rect.x + 1, rect.y + 1, rect.width - 2, rect.height - 2, ring, 1.0f, 0); } - // Waveform plot through the kit's shared primitive (FA3): the SAME per-pixel-column - // min/max envelope draw the VST editor hero + browser cards use — one algorithm, one - // look, everywhere. The oversampled env (see drawRegionGrid's binWidth) collapses per - // column via peaks::columnMinMax inside the kit; an empty env draws just the midline. drawWaveform(bmp, cell, env); - // L7 decorative metadata overlay, drawn last so it sits over the waveform. if (sample) drawCardMeta(bmp, rect, *sample); } -// --- Kit draw adapters (Phase L) ---------------------------------------------- -// -// All panel text draws through the kit's cached AA font (draw_kit::text), NOT raw GDI DrawText -// (retired at L1). L2 re-roles every color through the pure `theme` module and draws surfaces -// via the kit (fillSurface / drawButton). These thin adapters bridge the panel's RECT-based -// geometry helpers to the kit's KitBox and give the panel a KitColor->LICE_pixel boundary for -// the few raw borders it still draws over kit surfaces. The kit owns the font lifecycle -// (kitFontsInit/Shutdown, wired at panel open/close below). - KitBox toKitBox(const RECT& r) { return KitBox{r.left, r.top, r.right - r.left, r.bottom - r.top}; } -// KitColor -> LICE_pixel: all sites use the kit's toLice() from draw_kit.h — the single -// conversion boundary the kit enforces. No local alias needed. - -// L2 role/font-aware text: draws through the kit in a palette ROLE color and a chosen kit -// Font (the action bar uses Micro for the keybinding sub-label, Label for the name, Title for -// region headings). Takes a KitBox directly (the pure geometry the L2 modules return). void kitText(LICE_IBitmap* bmp, const KitBox& box, const char* txt, Font font, Role role, Align align) { text(bmp, box, txt, font, role, align); } -// The per-mode membership count that travels with the toggle (L4 §3): the number of leaves -// tagged into the currently ACTIVE mode. A compact readout beside the toggle. 0 when no -// session. (The Arrange default — untagged — is not counted; membership tracks tagged leaves.) -// A display-only tally over the model's public membership map — no model semantics duplicated. +// Number of leaves tagged into the currently active mode; 0 when no session. The +// Arrange default (untagged) is not counted — membership tracks tagged leaves only. int activeModeMemberCount() { if (!g_panel.session) return 0; const ViewModeModel& view = g_panel.session->view(); @@ -124,10 +84,9 @@ int activeModeMemberCount() { return n; } -// Draws the footer: the band + top divider, then the LEFT group (the narrow [Arrange|Design] -// toggle drawn as mode_switch segments over footer_bar's toggle box, the per-mode count, and -// the Tail BUTTON — L4 §4), the right-aligned version readout, and finally the Prune button -// set apart at the far right (warn). READ-ONLY: reads session state; input handlers mutate it. +// Draws the footer: band + top divider, LEFT group ([Arrange|Design] toggle, per-mode +// count, Tail BUTTON), the version readout, and the Prune button at the far right. +// READ-ONLY: reads session state; input handlers mutate it. void drawFooter(LICE_IBitmap* bmp, int w, int h) { const RECT f = panelFooter(w, h); if (f.top >= f.bottom) return; @@ -140,8 +99,7 @@ void drawFooter(LICE_IBitmap* bmp, int w, int h) { const FooterBarLayout fb = footerBarLayoutFor(w, h); - // [Arrange|Design] toggle — drawn as N mode_switch segments inside footer_bar's toggle box - // (the segment geometry stays owned by the pure mode_switch; footer_bar owns the box). The + // [Arrange|Design] toggle — N mode_switch segments inside footer_bar's toggle box. The // active mode's segment carries the accent; others hover-or-rest bg/cell. if (!fb.toggle.empty() && g_panel.session) { const ViewModeModel& view = g_panel.session->view(); @@ -166,8 +124,7 @@ void drawFooter(LICE_IBitmap* bmp, int w, int h) { } } - // Per-mode member count, a compact dim readout beside the toggle (L4 §3 — "the count - // travels with the toggle"). Passive text, not a control. + // Per-mode member count, a compact dim readout beside the toggle. Passive text, not a control. if (!fb.count.empty()) { const int members = activeModeMemberCount(); const std::string countLabel = @@ -176,8 +133,8 @@ void drawFooter(LICE_IBitmap* bmp, int w, int h) { countLabel.c_str(), Font::Micro, Role::TextDim, Align::Center); } - // Tail BUTTON (L4 §4) — a real kit button with rest/hover states; its click cycles the - // tail mode exactly as the old click-zone did. Label is the pure tailToggleLabel. + // Tail BUTTON — a real kit button with rest/hover states; its click cycles the tail + // mode. Label is the pure tailToggleLabel. if (!fb.tail.empty()) { const InteractionState state = hoverState(g_panel.hovered, HoverKind::TailButton, -1); const std::string label = tailToggleLabel(currentTail()); @@ -185,17 +142,13 @@ void drawFooter(LICE_IBitmap* bmp, int w, int h) { drawButton(bmp, box, label.c_str(), state, /*warn=*/false); } - // Version/channel readout (Phase V, V3/V4), right-aligned, unobtrusive. appVersion() - // renders the configured version string on stable and that string plus "-beta" on beta, - // so a beta panel self-identifies. It sits inside the space footer_bar reserves at the - // right (rightReserve) and clears the prune button (prune_button::rightInset). Dim, - // passive identification (V3). + // Version/channel readout. appVersion() renders the configured version string on + // stable and that string plus "-beta" on beta, so a beta panel self-identifies. kitText(bmp, KitBox{f.left, f.top, (f.right - f.left) - 8, f.bottom - f.top}, appVersion().c_str(), Font::Micro, Role::TextDim, Align::Right); - // Prune button — set apart at the far RIGHT (the ONLY warn-colored, byte-deleting control), - // honoring hover. No-op when suppressed (footer too narrow). Order reads left (benign, - // frequent) -> right (destructive, rare) per the L4 footer contract. + // Prune button — the ONLY warn-colored, byte-deleting control. No-op when suppressed + // (footer too narrow). const ButtonRect pb = pruneButtonRectFor(w, h); if (!pb.empty()) { const InteractionState state = hoverState(g_panel.hovered, HoverKind::PruneButton, -1); @@ -204,14 +157,12 @@ void drawFooter(LICE_IBitmap* bmp, int w, int h) { } } -// Draws one task-grouped toolbar through the L1 kit: a bg/panel band, then each visible button -// as a kit drawButton (rest/hover/disabled) with the action short label on the single-row face. -// Overflow drops WHOLE trailing buttons (the pure layout returns only the buttons that fit), so -// nothing is drawn clipped. `hoverKind` selects which HoverKind this bar's buttons use -// (TopBarButton / BottomBarButton) so the two toolbars' hover states never cross. `topDivider` -// draws a hairline at the band's top edge (the bottom toolbar's elevation over the split body); -// the top toolbar draws it at its bottom edge instead. Key binding help is in the hover tooltip -// (L6), not on the button face — the face shows only shortLabel. +// Draws one task-grouped toolbar through the kit. Overflow drops WHOLE trailing buttons +// (the pure layout returns only the buttons that fit), so nothing is drawn clipped. +// `hoverKind` selects which HoverKind this bar's buttons use so the two toolbars' hover +// states never cross. `topDivider` draws the hairline at the band's top edge (bottom +// toolbar) vs. bottom edge (top toolbar). Key binding help is in the hover tooltip, not +// on the button face. void drawToolbar(LICE_IBitmap* bmp, const ActionBarRect& bar, const std::vector& rows, HoverKind hoverKind, bool topDivider) { if (bar.height <= 0 || bar.width <= 0) return; @@ -230,17 +181,14 @@ void drawToolbar(LICE_IBitmap* bmp, const ActionBarRect& bar, const ActionBarRow& row = rows[static_cast(s.index)]; const int cmd = resolveBarCommandId(row); - // State: Disabled when the action is not registered on this channel OR the row is gated - // off (L5 opposite-mode enablement — the tag buttons for the ACTIVE mode); else Hover - // when hovered, else Rest. (The bar's actions are stateless triggers — no Active/Pressed.) + // Disabled when unregistered on this channel or gated off (opposite-mode + // enablement); else Hover when hovered, else Rest — these are stateless triggers. InteractionState state = InteractionState::Rest; if (cmd == 0 || !row.enabled) state = InteractionState::Disabled; else if (g_panel.hovered.kind == hoverKind && g_panel.hovered.index == s.index) state = InteractionState::Hover; - // The button surface (drawButton draws the micro-gradient + rounded border + honors - // the state). The label is drawn separately so the text role tracks the state correctly; - // pass no label to drawButton. + // Label drawn separately (not passed to drawButton) so its text role tracks state. const KitButtonBox box{KitBox{s.x, s.y, s.width, s.height}}; drawButton(bmp, box, /*label=*/nullptr, state, /*warn=*/false); @@ -251,31 +199,22 @@ void drawToolbar(LICE_IBitmap* bmp, const ActionBarRect& bar, } } -// --- Top-toolbar overflow ("⋯" More) menu (L5 refinement 1) ------------------- -// -// The three rare capture variants live only in this popup. The button is drawn kit-style (rest/ -// hover) at the far right of the top band; a click opens a REAPER/host TrackPopupMenu listing the -// variants, each firing its existing registered command id via NamedCommandLookup/Main_OnCommand -// (the SAME contract the visible buttons use — no action changes). A transient OS menu is fine -// for panel-external chrome (brief §1); only the button geometry (overflow_menu) is pure. - -// Draws the far-right More button (rest/hover). No-op when suppressed (band too narrow). +// Draws the far-right More button (rest/hover) — the entry to the top-toolbar overflow +// popup listing the rare capture variants. No-op when suppressed (band too narrow). void drawMoreButton(LICE_IBitmap* bmp, int w) { const MenuButtonRect mb = topMenuButtonRect(w); if (mb.empty()) return; const InteractionState state = hoverState(g_panel.hovered, HoverKind::MoreButton, -1); const KitButtonBox box{KitBox{mb.x, mb.y, mb.width, mb.height}}; drawButton(bmp, box, /*label=*/nullptr, state, /*warn=*/false); - // The glyph: three ASCII dots (portable — no UTF-8/codepage dependency in the LICE text - // path). Drawn as text so it picks up the kit font + AA. Reads as the conventional "More". + // Three ASCII dots (portable — no UTF-8/codepage dependency in the LICE text path). kitText(bmp, KitBox{mb.x, mb.y, mb.width, mb.height}, "...", Font::Label, Role::TextPrimary, Align::Center); } -// Draws the hover-delay tooltip over the given anchor button, if a tooltip is due (the current -// hover is a toolbar button AND it has been hovered past kTooltipDelayMs). Drawn LAST in the -// paint so it overlays the toolbars. The box is placed by the pure tooltip module (below the -// anchor, flipping above near the bottom edge, clamped to the client). +// Draws the hover-delay tooltip over the given anchor button, if due. Drawn LAST so it +// overlays the toolbars. Box placement (below anchor, flip above near the bottom edge, +// clamp to client) is the pure tooltip module's. void drawTooltip(LICE_IBitmap* bmp, int w, int h) { if (!g_panel.tooltipShown) return; std::string txt; @@ -295,8 +234,6 @@ void drawTooltip(LICE_IBitmap* bmp, int w, int h) { kitText(bmp, box, txt.c_str(), Font::Label, Role::TextPrimary, Align::Center); } -// --- Drawing: a grid region --------------------------------------------------- - // Draws one region's grid of thumbnails (or an empty-state line) clipped to its // viewport. `selectionOwner` is true when this region holds the live selection, so // its cells show selection/focus chrome; the other region draws plain. @@ -311,14 +248,12 @@ void drawRegionGrid(LICE_IBitmap* bmp, const RECT& region, bool isBanks, return; } - // L7: iterate the bank's SPARSE slot layout (slot order, gaps included), not the dense + // Iterate the bank's SPARSE slot layout (slot order, gaps included), not the dense // BankModel insertion order. Selection/focus are keyed by the occupied-ordinal (selection // space); a slot maps back to its ordinal via selectionForSlot. const RegionDisplay disp = regionDisplay(region, isBanks, reg); - // FA3 gap-free: request one bin per drawn pixel column; drawWaveform's - // peaks::columnMinMax exact partition makes every column gap-free — overbinning - // produces byte-identical pixels at higher memory/CPU cost. computeThumbnail clamps - // the request to the frame count. + // Request one bin per drawn pixel column; drawWaveform's peaks::columnMinMax exact + // partition makes every column gap-free regardless. computeThumbnail clamps to frame count. const int binWidth = kWaveformOversample * waveformColumnCount(KitBox{0, 0, kGrid.cellWidth, kGrid.cellHeight}); for (const SlotCellRect& r : disp.slotRects) { @@ -327,9 +262,8 @@ void drawRegionGrid(LICE_IBitmap* bmp, const RECT& region, bool isBanks, const std::string id = disp.idAtSlot(r.slot); if (id.empty()) { // Interior gap slot: a subtle empty-slot treatment through the kit — a hairline - // outline on bg/cell, clearly NOT a card (decorative, per the L7 spec). No - // selection/focus/waveform, and not a hover or hit target (the grid never tracks - // cell hover; a click on an empty slot clears selection like any grid miss). + // outline on bg/cell, clearly NOT a card. No selection/focus/waveform, and not a + // hover or hit target (a click on an empty slot clears selection like any grid miss). fillSurface(bmp, KitBox{rect.x, rect.y, rect.width, rect.height}, Role::BgCell, InteractionState::Rest); LICE_DrawRect(bmp, rect.x, rect.y, rect.width, rect.height, @@ -349,12 +283,11 @@ void drawRegionGrid(LICE_IBitmap* bmp, const RECT& region, bool isBanks, } } -// L7: draws the per-slot reorder/replace drop-target highlight on the target slot's cell, but +// Draws the per-slot reorder/replace drop-target highlight on the target slot's cell, but // ONLY when a same-bank in-grid drag (Reorder or Replace) is live over THIS region (the drag -// source region). An accent/HOT outline (distinct from the accent/tertiary purple selection -// border, per the spec's "must not be confusable" constraint); Replace draws a doubled outline -// so an Alt-over-occupied replace reads as a stronger "swap" cue than a plain reorder. No-op -// for a move/copy/OS drag or when the pointer is off any slot (dragTargetSlot < 0). +// source region). An accent/HOT outline, distinct from the accent/tertiary purple selection +// border so it is never confusable; Replace draws a doubled outline so an Alt-over-occupied +// replace reads as a stronger "swap" cue than a plain reorder. void drawCardDropTarget(LICE_IBitmap* bmp, const RECT& region, bool isBanks, Region reg) { if (!g_panel.dragging) return; if (g_panel.cardGesture != CardGesture::Reorder && @@ -365,8 +298,8 @@ void drawCardDropTarget(LICE_IBitmap* bmp, const RECT& region, bool isBanks, Reg const RECT grid = regionGridRect(region, isBanks); const RegionDisplay disp = regionDisplay(region, isBanks, reg); - // Use the drop rects (includes the trailing row past maxSlot) so a beyond-extent - // target slot gets a visible highlight cue, not silence. + // The drop rects include a trailing row past maxSlot so a beyond-extent target + // slot gets a visible highlight cue, not silence. const int gridW = grid.right - grid.left; const int maxSlot = disp.bank ? disp.bank->slots.maxSlot() : -1; std::vector dropRects = computeSlotRectsForDrop(maxSlot, gridW, kGrid); @@ -386,26 +319,24 @@ void drawCardDropTarget(LICE_IBitmap* bmp, const RECT& region, bool isBanks, Reg void drawRegionHeader(LICE_IBitmap* bmp, const RECT& region, const char* title, const std::string& activeName, bool poolBtnIsPool) { const RECT hdr = regionHeaderRect(region); - // Region header band (kit bg/panel — a raised region title bar). A hairline underline. fillSurface(bmp, KitBox{hdr.left, hdr.top, hdr.right - hdr.left, hdr.bottom - hdr.top}, Role::BgPanel, InteractionState::Rest); LICE_Line(bmp, hdr.left, hdr.bottom - 1, hdr.right, hdr.bottom - 1, toLice(roleColor(Role::LineHairline)), 1.0f, 0, false); - // Title, left (Font::Title — a region heading). The two regions are distinct KINDS of - // container, so the title carries a CATEGORICAL accent (DS-2 revised: secondary/tertiary - // mark kinds, never intensity) — Pool = secondary teal, Banks = tertiary purple. This is - // a category mark, NOT the "what's live" signal (that stays the primary-lime "Active:" - // readout beside it), keeping primary reserved for the live/active layer. + // Title, left. The two regions are distinct KINDS of container, so the title carries a + // CATEGORICAL accent (secondary/tertiary mark kinds, never intensity) — Pool = secondary + // teal, Banks = tertiary purple. This is a category mark, NOT the "what's live" signal + // (that stays the primary-lime "Active:" readout beside it). RECT titleRc = hdr; titleRc.left += 8; titleRc.right = titleRc.left + 120; const Role titleRole = poolBtnIsPool ? Role::AccentSecondary : Role::AccentTertiary; kitText(bmp, toKitBox(titleRc), title, Font::Title, titleRole, Align::Left); - // Active-bank readout — the UNMISTAKABLE indicator (settled B4 constraint), in the PRIMARY - // accent role in BOTH region headers so the active/capture-target bank is legible even when - // it is not the shown tab and even when it is the pool. Primary = "what's live" (DS-2). + // Active-bank readout — the UNMISTAKABLE indicator, in the PRIMARY accent role in BOTH + // region headers so the active/capture-target bank is legible even when it is not the + // shown tab and even when it is the pool. Primary = "what's live". const std::string readout = "Active: " + activeName; RECT actRc = hdr; actRc.left = titleRc.right + 6; @@ -413,8 +344,8 @@ void drawRegionHeader(LICE_IBitmap* bmp, const RECT& region, const char* title, if (actRc.right > actRc.left) kitText(bmp, toKitBox(actRc), readout.c_str(), Font::Label, Role::AccentPrimary, Align::Left); - // Full-height toggle button: an arrow glyph. In split it means "maximize this region"; - // when this region is already full it means "restore the split". Kit drawButton + hover. + // Arrow glyph: in split it means "maximize this region"; when already full it means + // "restore the split". const RECT btn = fullHtBtnRect(region); const bool thisFull = poolBtnIsPool ? (g_panel.fullHeight == BankPanelFullHeight::PoolOnly) @@ -434,7 +365,6 @@ void drawRegionHeader(LICE_IBitmap* bmp, const RECT& region, const char* title, void drawTabStrip(LICE_IBitmap* bmp, const RECT& region) { const TabStripRect strip = banksTabStripRect(region); if (strip.height <= 0) return; - // Tab strip band (kit bg/base — recessed relative to the region header above it). fillSurface(bmp, KitBox{strip.x, strip.y, strip.width, strip.height}, Role::BgBase, InteractionState::Rest); @@ -474,9 +404,8 @@ void drawTabStrip(LICE_IBitmap* bmp, const RECT& region) { const bool hovered = g_panel.hovered.kind == HoverKind::Tab && g_panel.hovered.index == tr.index; - // Surface state: the ACTIVE bank (capture target) carries the accent (Active); a drag - // drop-target reads Dragging; the SHOWN (browsed) tab reads Pressed (recessed-lit); - // else hover-or-rest bg/cell. + // The ACTIVE bank (capture target) carries the accent; a drag drop-target reads + // Dragging; the SHOWN (browsed) tab reads Pressed; else hover-or-rest bg/cell. const KitBox tb{tr.x, tr.y, tr.width, tr.height}; InteractionState state = InteractionState::Rest; if (active) state = InteractionState::Active; @@ -510,8 +439,6 @@ std::string activeBankName() { } // namespace -// --- Full paint --------------------------------------------------------------- - void paintPanel(HWND hwnd, HDC hdc) { RECT cr{}; GetClientRect(hwnd, &cr); @@ -525,16 +452,14 @@ void paintPanel(HWND hwnd, HDC hdc) { const std::string projectDir = currentProjectDir(); const std::string activeName = activeBankName(); - // Pool region (top). if (poolShown()) { const RECT region = poolRegionRect(w, h); drawRegionHeader(&bmp, region, "Pool", activeName, /*poolBtnIsPool=*/true); drawRegionGrid(&bmp, region, /*isBanks=*/false, indexForRegion(Region::Pool), "No samples in the pool yet. Capture one to see it here.", g_panel.focusedRegion == Region::Pool, projectDir, Region::Pool); - // Drop-target highlight for the pool region during a MOVE/COPY drag (a whole-grid - // outline signalling "drop here to move/copy into this bank"). Suppressed for a - // same-bank reorder (that shows a per-SLOT highlight below, not the whole grid). + // Whole-grid drop-target outline for a MOVE/COPY drag; a same-bank reorder shows a + // per-SLOT highlight instead (drawCardDropTarget below). if (g_panel.dragging && g_panel.dropKind == DropKind::PoolRegion && (g_panel.cardGesture == CardGesture::Move || g_panel.cardGesture == CardGesture::Copy)) { @@ -543,13 +468,9 @@ void paintPanel(HWND hwnd, HDC hdc) { grid.right - grid.left - 2, grid.bottom - grid.top - 2, toLice(roleColor(Role::AccentHot)), 1.0f, 0); } - // L7 per-slot reorder/replace target highlight (source = pool). An accent/hot outline - // on the target slot's cell — distinct from the accent/tertiary purple selection - // border, so it is never confusable with a selected card. drawCardDropTarget(&bmp, region, /*isBanks=*/false, Region::Pool); } - // Split divider. if (poolShown() && banksShown()) { const RECT body = splitBody(w, h); const int dy = body.top + (body.bottom - body.top - kSplitDividerHeight) / 2; @@ -557,7 +478,6 @@ void paintPanel(HWND hwnd, HDC hdc) { toLice(roleColor(Role::BgBase)), 1.0f, 0); } - // Named-banks region (bottom). if (banksShown()) { const RECT region = banksRegionRect(w, h); drawRegionHeader(&bmp, region, "Banks", activeName, /*poolBtnIsPool=*/false); @@ -575,9 +495,8 @@ void paintPanel(HWND hwnd, HDC hdc) { ? "Select or create a named bank." : "This bank is empty. Move samples here from the pool.", g_panel.focusedRegion == Region::Banks, projectDir, Region::Banks); - // Drop-target highlight for the banks region during a drag. BanksRegion fires - // when the pointer is in the grid but not on a specific tab; Tab draws its own - // highlight on the individual tab (drawTabStrip above handles that case). + // BanksRegion fires when the pointer is in the grid but not on a specific tab; + // Tab draws its own highlight on the individual tab (drawTabStrip above). if (g_panel.dragging && g_panel.dropKind == DropKind::BanksRegion && (g_panel.cardGesture == CardGesture::Move || g_panel.cardGesture == CardGesture::Copy)) { @@ -586,16 +505,12 @@ void paintPanel(HWND hwnd, HDC hdc) { grid.right - grid.left - 2, grid.bottom - grid.top - 2, toLice(roleColor(Role::AccentHot)), 1.0f, 0); } - // L7 per-slot reorder/replace target highlight (source = banks region). drawCardDropTarget(&bmp, region, /*isBanks=*/true, Region::Banks); } - // L4 three-zone chrome + L5 refinements: TOP toolbar (frequent capture + placement) tiles - // into the band MINUS the far-right More-button reserve; the More button is drawn over the - // band's reserved right strip; the BOTTOM toolbar (four opposite-mode tag buttons + Show - // Both); then the footer (mode toggle + count + Tail button + Prune). Drawn last so they sit - // over the split body's edges. drawToolbar fills only its passed (action) rect, so fill the - // WHOLE top band first — otherwise the reserved right strip behind the More button is bare. + // Toolbars + footer drawn last so they sit over the split body's edges. drawToolbar + // fills only its passed (action) rect, so fill the WHOLE top band first — otherwise + // the reserved right strip behind the More button is bare. fillSurface(&bmp, KitBox{0, 0, w, kTopToolbarHeight}, Role::BgPanel, InteractionState::Rest); drawToolbar(&bmp, topToolbarActionRect(w), topBarRows(), HoverKind::TopBarButton, /*topDivider=*/false); @@ -604,7 +519,6 @@ void paintPanel(HWND hwnd, HDC hdc) { /*topDivider=*/true); drawFooter(&bmp, w, h); - // The custom hover-delay tooltip overlays everything (L5 refinement 2). drawTooltip(&bmp, w, h); BitBlt(hdc, 0, 0, w, h, bmp.getDC(), 0, 0, SRCCOPY); diff --git a/src/shell/panel/panel_state.h b/src/shell/panel/panel_state.h index 1cc153c..f169dbf 100644 --- a/src/shell/panel/panel_state.h +++ b/src/shell/panel/panel_state.h @@ -1,36 +1,22 @@ #pragma once -// panel_state — INTERNAL shared state + cross-seam contract of the docked bank panel -// (Q-W2: bank_panel.cpp split into eight TUs under shell/panel/). Included ONLY by the -// panel's own translation units (panel_render / panel_thumbnails / panel_audition / -// panel_input / panel_layout / panel_drag / panel_bank_ops / panel_window) — consumers -// outside the panel use the per-seam public headers (panel_window.h / panel_input.h / -// panel_bank_ops.h / panel_layout.h). +// panel_state — INTERNAL shared state + cross-seam contract of the docked bank panel. +// Included ONLY by the panel's own TUs; outside consumers use the per-seam public +// headers (panel_window.h / panel_input.h / panel_bank_ops.h / panel_layout.h). +// Cross-seam calls are plain free functions — direct call-through, no virtual dispatch +// (audition and per-mouse-move paths must stay direct calls). // -// What lives here: -// * PanelState (the one shared state blob, defined in panel_window.cpp) + the small -// enums/structs the seams speak (Region / DropKind / Hover / RegionDisplay / -// ActionBarRow) and the shared layout constants. -// * The cross-seam free-function declarations, grouped by OWNING TU. Everything is a -// plain free function — direct call-through, no interface, no virtual dispatch -// (T4-28: the audition path and the per-mouse-move path must stay direct calls). -// * Explicit using-declarations pulling the pure modules' symbols into -// reasampler::panel from their REAL namespace homes (Q-W1 sub-namespaces). The -// interim core/namespaces.h shim is GONE (deleted in Q-W6 with the last split); -// every symbol below names its true home. -// -// REFERENCE-INVALIDATION GUARDRAIL (CONTEXT.md §Multi-bank): a bank-structural -// mutation (create/delete/evacuate/activate/move) can reallocate the book's vector, -// so a BankModel& / Bank* must NEVER be cached across one. Every seam resolves fresh -// AFTER any mutation and passes bank IDS (not references) into the model ops. +// REFERENCE-INVALIDATION GUARDRAIL: a bank-structural mutation (create/delete/ +// evacuate/activate/move) can reallocate the book's vector, so a BankModel& / Bank* +// must NEVER be cached across one. Every seam resolves fresh after any mutation and +// passes bank IDS (not references) into the model ops. #include #include #include #include -// SWELL / platform types (HWND, RECT, HMENU). 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). +// 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). #ifdef _WIN32 #include #else @@ -39,37 +25,35 @@ #include "wdltypes.h" #include "swell/swell.h" -// REAPER SDK types only (preview_register_t, MediaTrack, ReaProject). The API function -// POINTERS are declared per-TU (REAPERAPI_MINIMAL + per-TU WANT list) — main.cpp owns -// the definitions (CLAUDE.md §contract). +// REAPER API function pointers are declared per-TU; main.cpp owns the definitions. #include "reaper_plugin.h" -#include "core/audio/peaks.h" // audio::Envelope — thumbnail cache payload -#include "core/capture/capture_paths.h" // capture::resolveBankFile / normalizeSlashes -#include "core/capture/render_settings.h" // capture::CaptureActionDef / captureActionTable -#include "core/capture/tail_control.h" // capture::TailSetting — the tail toggle state -#include "core/model/bank_book.h" // BankBook / Bank / SlotMap (flat reasampler until its split wave) -#include "core/model/bank_model.h" // model::BankModel / model::Sample -#include "core/ui/action_bar.h" // ui::ActionBarRect / slots / clusters -#include "core/ui/bank_grid.h" // ui::GridSpec / Selection / ThumbnailKey / CellRect -#include "core/ui/card_drag.h" // ui::CardGesture / SlotCellRect / gesture decisions -#include "core/ui/card_meta.h" // ui::MusicalLength / formatters -#include "core/ui/component_geometry.h" // ui::KitBox / KitButtonBox / waveformColumnCount -#include "core/ui/drag_out.h" // ui::DragState / PanelClientRect / decideGesture -#include "core/ui/footer_bar.h" // ui::FooterBarLayout / computeFooterBar -#include "core/ui/mode_enable.h" // ui::tagButtonEnabled / TagTarget -#include "core/ui/overflow_menu.h" // ui::MenuButtonSpec / computeMenuButton -#include "core/ui/prune_button.h" // ui::ButtonRect / computePruneButton -#include "core/ui/tab_strip.h" // ui::TabStripSpec / layout / hit-test -#include "core/ui/theme.h" // ui::Role / InteractionState / KitColor -#include "core/ui/tooltip.h" // ui::TooltipBox / computeTooltip / stripActionPrefix -#include "core/version/app_version.h" // version::channelCommandId / appVersion / dock identity -#include "core/view/guid_diff.h" // view::GuidBaseline — new-content detection -#include "core/view/lane_keys.h" // view::isOnManualLane -#include "core/view/mode_switch.h" // view::SegmentRect / computeSegmentRects -#include "core/wire/instrument_drop.h" // wire::buildInstrumentDropPreset (S17) +#include "core/audio/peaks.h" +#include "core/capture/capture_paths.h" +#include "core/capture/render_settings.h" +#include "core/capture/tail_control.h" +#include "core/model/bank_book.h" +#include "core/model/bank_model.h" +#include "core/ui/action_bar.h" +#include "core/ui/bank_grid.h" +#include "core/ui/card_drag.h" +#include "core/ui/card_meta.h" +#include "core/ui/component_geometry.h" +#include "core/ui/drag_out.h" +#include "core/ui/footer_bar.h" +#include "core/ui/mode_enable.h" +#include "core/ui/overflow_menu.h" +#include "core/ui/prune_button.h" +#include "core/ui/tab_strip.h" +#include "core/ui/theme.h" +#include "core/ui/tooltip.h" +#include "core/version/app_version.h" +#include "core/view/guid_diff.h" +#include "core/view/lane_keys.h" +#include "core/view/mode_switch.h" +#include "core/wire/instrument_drop.h" -#include "shell/panel/panel_layout.h" // BankPanelFullHeight — the split-state enum +#include "shell/panel/panel_layout.h" namespace reasampler { class ReaSamplerSession; @@ -77,14 +61,6 @@ class ReaSamplerSession; namespace reasampler::panel { -// --- Real-namespace-home using-declarations ----------------------------------- -// -// The panel's pre-split internals reference the pure modules' symbols unqualified; -// these explicit per-symbol usings keep those references valid while documenting -// each symbol's Q-W1 home. Flat-`reasampler` symbols (BankBook / ViewModeModel / -// the draw_kit shell / the shell/bank_ops verbs / ...) resolve via the enclosing -// namespace and need no using. - // core/ui using ui::ActionBarRect; using ui::ActionBarSlot; @@ -200,73 +176,46 @@ using version::dockTitle; // core/wire using wire::buildInstrumentDropPreset; -// --- Layout constants --------------------------------------------------------- -// -// L2: every panel COLOR comes from the pure `theme` module by ROLE (drawn through the -// L1 kit — fillSurface / drawButton / kit text). Only the pixel LAYOUT metrics (band -// heights, grid/tab specs, insets) live here, shared by the layout/render/input/drag -// seams so draw and hit-test can never drift. +// All color comes from `theme` by role, drawn through the kit. Only pixel layout +// metrics live here, shared by layout/render/input/drag so draw and hit-test can +// never drift. inline const GridSpec kGrid{/*cellWidth=*/140, /*cellHeight=*/84, /*gap=*/10}; -// --- Footer (Phase L, L4) ----------------------------------------------------- -// The footer carries a task-cluster of small persistent controls: the narrowed -// [Arrange|Design] mode toggle, a compact per-mode count, the Tail BUTTON (L4 §4 — -// a real kit button, no longer a click-zone), and the set-apart Prune button at the -// right. Taller than the L2 footer to host the toggle segments + button chrome cleanly. -// Layout is the pure footer_bar (left group) + prune_button (right); this is the band height. +// Footer: [Arrange|Design] toggle, per-mode count, Tail button, Prune button +// (footer_bar left group + prune_button right). inline constexpr int kFooterHeight = 30; -// --- Toolbars (Phase L, L4) --------------------------------------------------- -// TWO task-grouped toolbars, both drawn through the pure action_bar module: -// * kTopToolbarHeight — the TOP toolbar (capture + placement clusters) at the very top of -// the client, where the eye lands (L4 §1). Replaces the L2 mode-switch header there. -// * kBottomToolbarHeight — the BOTTOM toolbar (Design-View tag/switch verbs) directly above -// the footer (L4 §2). This is the L2 action-bar band, repurposed. -inline constexpr int kTopToolbarHeight = 28; // single-row label face (L6: keybinding sub-row removed) -inline constexpr int kBottomToolbarHeight = 28; // same shape — both bars consistent +// Two task-grouped toolbars drawn through action_bar: top = capture + placement, +// bottom = Design-View tag/switch verbs, directly above the footer. +inline constexpr int kTopToolbarHeight = 28; +inline constexpr int kBottomToolbarHeight = 28; -// --- Tooltip (Phase L, L5) ---------------------------------------------------- -// The custom hover-delay tooltip's timing + approximate text metrics. The delay matches the -// platform convention (~0.5 s) so the tooltip is deliberate, not twitchy; it is driven off the -// OnTimer poll (bankPanelRefresh) + WM_MOUSEMOVE, so no dedicated timer is added. The kit font -// is AA and proportional, so the width is estimated from a per-char average (the tooltip box is -// generous — a slight over/under-estimate only pads the box, never clips the text). +// Hover-delay tooltip timing, driven off the OnTimer poll (no dedicated timer). Kit +// font is proportional, so char width is a generous estimate (pads, never clips). inline constexpr unsigned int kTooltipDelayMs = 500; -inline constexpr int kTooltipCharPx = 7; // approx px per char at Font::Label (generous) -inline constexpr int kTooltipTextH = 14; // approx line height at Font::Label +inline constexpr int kTooltipCharPx = 7; +inline constexpr int kTooltipTextH = 14; -// --- Vertical split + region headers + tab strip (Phase B4; L4 re-home) ------- -// -// The client area, top to bottom (L4): TOP toolbar (kTopToolbarHeight, capture + placement) | -// split body | BOTTOM toolbar (kBottomToolbarHeight, Design-View verbs) | footer -// (kFooterHeight — mode toggle + count + Tail button + Prune). The split body holds the pool -// region (top) and the named-banks region (bottom). Each region opens with a REGION HEADER -// band: a title, the active-bank readout, and a full-height toggle button. The named-banks -// region's header ALSO hosts the LICE tab strip and a "+" create button. -inline constexpr int kRegionHeaderHeight = 24; // per-region title/toggle band -inline constexpr int kTabStripHeight = 26; // the named-banks tab strip band -inline constexpr int kSplitDividerHeight = 3; // the horizontal divider between regions -inline constexpr int kFullHtBtnWidth = 22; // the square full-height toggle button -inline constexpr int kCreateBtnWidth = 22; // the "+" create-bank button +// Client area top to bottom: top toolbar | split body | bottom toolbar | footer. +inline constexpr int kRegionHeaderHeight = 24; +inline constexpr int kTabStripHeight = 26; +inline constexpr int kSplitDividerHeight = 3; +inline constexpr int kFullHtBtnWidth = 22; +inline constexpr int kCreateBtnWidth = 22; -// Tab strip metrics (the pure tab_strip owns the math; these are its inputs). inline const TabStripSpec kTabSpec{/*tabWidth=*/96, /*chevronWidth=*/20}; -// The spec for the far-right More ("⋯") overflow-menu button. One source of truth for its -// geometry + the reserve the action_bar leaves for it (L5). +// Far-right More ("...") overflow-menu button: one source of truth for its +// geometry + the reserve action_bar leaves for it. inline const MenuButtonSpec kMenuBtnSpec{/*buttonWidth=*/28, /*rightInset=*/6, /*verticalInset=*/3, /*minLeftInset=*/40}; -// The toolbar layout spec (the panel's 8px-grid density decision). One source of truth shared -// by both toolbars' draw and hit-test (identical button shape top and bottom). L5 refinement 5: -// clusterGap widened 16 -> 24 (a 6:1 inter/intra ratio) so semantic groups read AS groups. L6: -// bindingHeight / minSplitHeight removed — buttons are single-row label-only faces now. +// Shared by both toolbars' draw and hit-test (identical button shape top and +// bottom). clusterGap is wider than buttonGap so semantic groups read as groups. inline const ActionBarSpec kBarSpec{/*buttonWidth=*/108, /*buttonGap=*/4, /*clusterGap=*/24, /*sidePad=*/8, /*verticalInset=*/3}; -// --- Panel state -------------------------------------------------------------- - struct CachedThumbnail { Envelope envelope; int width = 0; @@ -276,32 +225,28 @@ struct CachedThumbnail { // input. The move/copy source is the focused region's displayed bank. enum class Region { Pool, Banks }; -// What a drag is dropping onto, resolved live under the pointer during a drag. -// BanksRegion fires when the pointer is anywhere in the named-banks grid that is NOT -// on a specific tab (tab takes precedence — more specific wins). The resolved bank is -// always shownBankId. +// What a drag is dropping onto, resolved live under the pointer. BanksRegion fires +// when the pointer is anywhere in the named-banks grid that is NOT on a specific tab +// (tab takes precedence); the resolved bank is always shownBankId. enum class DropKind { None, PoolRegion, Tab, BanksRegion }; -// --- Hover model (Phase L, L2) ------------------------------------------------ -// // The hovered interactive element, resolved live in WM_MOUSEMOVE so the kit draws its -// hover state on that element only (the "hover on every interactive element" + "sub-frame -// feedback = the perception of speed" L2 constraint). SWELL exposes no WM_MOUSELEAVE (grep -// of vendor/WDL/WDL/swell — none), so hover is cleared by a move that resolves to None -// rather than a leave message; the panel is Windows-only (D5) but this stays portable-safe. +// hover state on that element only. SWELL exposes no WM_MOUSELEAVE (confirmed: no hit in +// vendor/WDL/WDL/swell), so hover is cleared by a move that resolves to None rather than a +// leave message; the panel is Windows-only but this stays portable-safe. // `index` disambiguates within a kind (action-bar button index, tab index); -1 when N/A. enum class HoverKind { None, - TopBarButton, // a button in the TOP toolbar (index = flat action index into topBarRows) - BottomBarButton, // a button in the BOTTOM toolbar (index = flat action index into bottomBarRows) - MoreButton, // the TOP toolbar's far-right "⋯" overflow-menu button (L5) + TopBarButton, // index = flat action index into topBarRows + BottomBarButton, // index = flat action index into bottomBarRows + MoreButton, PruneButton, - FullHtPool, // pool region full-height toggle - FullHtBanks, // banks region full-height toggle - CreateBank, // the "+" create-bank button - Tab, // a named-bank tab (index = tab ordinal) - TailButton, // the footer Tail button (L4 §4 — a real button, was a click-zone) - ModeSegment, // a footer mode-toggle segment (index = segment ordinal) + FullHtPool, + FullHtBanks, + CreateBank, + Tab, // index = tab ordinal + TailButton, + ModeSegment, // index = segment ordinal }; struct Hover { @@ -312,9 +257,8 @@ struct Hover { bool operator!=(const Hover& o) const { return !(*this == o); } }; -// The kit interaction state for an interactive element: Hover when this (kind,index) is the -// live hovered element, else Rest. Active/Pressed are decided per-element by the caller (e.g. -// an active tab draws Active regardless of hover); this is the base rest/hover resolver. +// Base rest/hover resolver; Active/Pressed are decided per-element by the caller +// (e.g. an active tab draws Active regardless of hover). inline InteractionState hoverState(const Hover& hovered, HoverKind kind, int index) { return (hovered.kind == kind && hovered.index == index) ? InteractionState::Hover : InteractionState::Rest; @@ -331,135 +275,83 @@ struct PanelState { std::unordered_map cache; - // --- Selection (per focused region) --------------------------------------- // One live selection, scoped to `focusedRegion`. Switching regions moves the - // selection with the focus (a click in the other region reseeds it there). + // selection with the focus. Selection selection; int selItemCount = 0; Region focusedRegion = Region::Pool; - // --- Hover (Phase L, L2) -------------------------------------------------- - // The live hovered interactive element (WM_MOUSEMOVE resolves it; the kit draws its - // hover state). Repaint fires only when this changes (sub-frame, no per-move jank). + // Resolved on WM_MOUSEMOVE; repaint fires only when this changes. Hover hovered; - // --- Tooltip (Phase L, L5) ------------------------------------------------ - // A custom LICE-kit hover-delay tooltip (NOT the native Win32/SWELL tooltip control): when a - // TOOLTIP-capable element (a toolbar button) stays hovered past kTooltipDelayMs, the panel - // draws a small overlay carrying the full, prefix-stripped action name. hoverSinceTick is the - // GetTickCount() at which the CURRENT hovered element was first entered (reset on every hover - // change); tooltipShown latches once the delay elapses so the OnTimer poll repaints exactly - // once when the tooltip appears. The last-seen pointer pos anchors nothing (the anchor is the - // hovered button's rect), but is kept so the OnTimer path can re-resolve without a live event. + // Custom hover-delay tooltip. hoverSinceTick resets on every hover change; + // tooltipShown latches once the delay elapses so the OnTimer poll repaints once. unsigned int hoverSinceTick = 0; bool tooltipShown = false; - // --- Vertical-split state ------------------------------------------------- BankPanelFullHeight fullHeight = BankPanelFullHeight::Split; - // The named bank whose grid the banks region shows (the SHOWN tab) — DISTINCT - // from the active/capture-target bank (book().activeBankId()). Empty when there - // are no named banks. Reconciled each fingerprint pass so it always names a live - // named bank (or is empty). + // The named bank the banks region shows — distinct from the active/capture-target + // bank. Reconciled each fingerprint pass so it always names a live bank (or empty). std::string shownBankId; - // Tab-strip horizontal scroll offset (px), clamped to the strip's max each frame. int tabScroll = 0; - // --- Drag (sample move between regions/onto a tab) ------------------------ - // A drag begins only after the pointer moves past a threshold from a press that - // landed on a SELECTED grid cell — this is how it is disambiguated from the M5 - // multi-select drag (which begins immediately on any grid press). See handleClick/ - // onMouseMove. dragging is true once the threshold is crossed. - bool dragArmed = false; // pressed on a selected cell; watching for threshold - bool dragging = false; // threshold crossed; a move-drag is in progress + // A drag begins only after the pointer moves past a threshold from a press + // that landed on a selected grid cell — disambiguates from the multi-select + // drag (which begins immediately on any grid press). + bool dragArmed = false; + bool dragging = false; int dragStartX = 0, dragStartY = 0; Region dragSourceRegion = Region::Pool; - std::string dragSourceBankId; // the bank the dragged samples come from - std::vector dragSampleIds;// snapshot of the selection at drag start - std::string dragPrimaryId; // the single card grabbed (the focus) — the L7 - // reorder/replace subject (see onLBtnUp dispatch) - DropKind dropKind = DropKind::None; // live drop target under the pointer + std::string dragSourceBankId; + std::vector dragSampleIds; + std::string dragPrimaryId; // the single card grabbed — the reorder/replace subject + DropKind dropKind = DropKind::None; std::string dropBankId; // destination bank id when dropKind==Tab - // --- L7 in-grid reorder/replace drag -------------------------------------- - // The live card gesture resolved by the pure card_drag::decideCardGesture each mouse- - // move (drives the cursor cue AND the drop dispatch), plus the same-bank target slot the - // pointer sits over (>= 0 only for a Reorder/Replace over the source bank's own grid; -1 - // otherwise). A Reorder highlights dragTargetSlot's cell; Replace + a live cursor cue - // signal the Alt-over-occupied case. Reset with the rest of the drag state on drop/cancel. + // Resolved each mouse-move by card_drag::decideCardGesture. dragTargetSlot >= 0 + // only for a Reorder/Replace over the source bank's own grid. CardGesture cardGesture = CardGesture::None; int dragTargetSlot = -1; - // --- S17 drop-and-load (InstrumentDrop) ----------------------------------- - // While a SINGLE-capture drag is over REAPER's own UI outside the panel, the drag is an - // InstrumentDrop heading for a track's TCP FX button. The shell hover-tracks the FX - // hotspot; on release over a valid target it adds a ReaSampler 9000 preloaded with the - // dragged capture (no OS drag, no timeline insert). instrumentDropTrack is the last - // resolved FX-hotspot track (null when the pointer is not over an FX button) — read on - // release. Only set/used on Windows (D5); the M11 OsDrag and internal drag are untouched. + // While a single-capture drag is over REAPER's own UI, heading for a track's TCP FX + // button: on release this adds a ReaSampler 9000 preloaded with the capture. Null + // when the pointer is not over an FX button. MediaTrack* instrumentDropTrack = nullptr; - // --- Tail-mode toggle ----------------------------------------------------- - // The authoritative tail setting 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. + // Authoritative tail setting lives in ReaSamplerSession, not here; panel reads it for + // drawing and mutates via footer click / scroll-wheel. bankPanelTailSetting is the + // capture actions' read seam. - // --- Audition preview ----------------------------------------------------- preview_register_t preview{}; PCM_source* previewSrc = nullptr; bool previewActive = false; bool previewInited = false; // guards double init / deinit - // --- New-content detection (D2 Wave 2) ------------------------------------ - // // Each timer tick diffs the live track+item GUID set against the previous tick to - // auto-tag content created SINCE the last tick into the then-active mode. The - // baseline carries the first-poll-after-open guard (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). + // auto-tag new content. GuidBaseline self-arms on first observe() so pre-existing + // content is never mass-tagged. Project-load re-arm is driven by persist's load + // signal, not a ReaProject* compare — a recycled address previously mis-tagged tracks. GuidBaseline contentBaseline; bool reloadPending = false; // set by bankPanelNotifyProjectLoaded; drained next detect tick }; -// The one shared panel state blob. Defined in panel_window.cpp (the lifecycle owner). +// Defined in panel_window.cpp (the lifecycle owner). extern PanelState g_panel; -// --- L7 slot-order display bridge --------------------------------------------- -// -// L7 re-maps cell index <-> sample identity: the grid draws in the bank's persisted -// SlotMap order (sparse, gap-preserving), NOT BankModel insertion order. regionDisplay -// (panel_layout.cpp) is the single place that resolves a region's display, composed -// purely from bank_book's slot order (orderedSampleIds) + card_drag's sparse slot rects -// (computeSlotRects) — the shell adds no layout math of its own. +// The grid draws in the bank's persisted SlotMap order (sparse, gap-preserving), +// NOT BankModel insertion order. regionDisplay (panel_layout.cpp) is the single +// place that resolves a region's display. // // TWO INDEX SPACES the whole panel must keep straight: -// * SLOT — a display position 0..maxSlot; gaps are empty slots that draw as empty -// cells and are valid drop targets. This is what pixels/hit-tests speak. -// * SELECTION — the DENSE occupied-ordinal [0, occupied) space the pure Selection / -// applyClick / navigate reason in. Selection index i <-> orderedIds[i]. -// Keyboard navigation therefore traverses ONLY occupied cells and SKIPS -// gaps (spec: skip-vs-land-on-gap is unspecified -> skip, documented here). -// RegionDisplay carries both plus the translation between them, resolved FRESH each call -// (never cached across a mutation, per the reference-invalidation guardrail). +// * SLOT — display position 0..maxSlot; gaps are empty, valid drop targets. +// What pixels/hit-tests speak. +// * SELECTION — the dense occupied-ordinal [0, occupied) space the pure +// Selection/applyClick/navigate reason in. Selection index i <-> +// orderedIds[i]. Keyboard nav therefore skips gaps. +// RegionDisplay carries both plus the translation, resolved fresh each call (never +// cached across a mutation, per the reference-invalidation guardrail). struct RegionDisplay { std::vector orderedIds; // occupied ids in slot order (selection space) std::vector slotRects; // one rect per slot 0..maxSlot, viewport coords @@ -486,27 +378,18 @@ struct RegionDisplay { int occupiedCount() const { return static_cast(orderedIds.size()); } }; -// --- Toolbar row vocabulary (Phase L, L4/L5/L6) -------------------------------- -// -// One action button: its channel-AGNOSTIC command-id suffix (composed with the channel prefix -// at fire time — never a hardcoded numeric id), its terse on-button FACE label, its full action -// NAME for the hover tooltip (already prefix-stripped — the "ReaSampler:" display prefix is -// dropped at build), and the task cluster it belongs to. The order of a toolbar's row list IS -// the flat action index the pure action_bar slots carry, so each list is built cluster-by-cluster -// in its toolbar's cluster order. Built by panel_layout (topBarRows / bottomBarRows / -// overflowMenuRows); consumed by the render draw, the input click routing, and the drag hover. +// One action button. Row order IS the flat action index the pure action_bar slots +// carry. Built by panel_layout; consumed by render, input, drag. struct ActionBarRow { std::string suffix; std::string shortLabel; std::string fullName; ActionCluster cluster = ActionCluster::Capture; - bool enabled = true; // L5: opposite-mode gate for the bottom-bar tag buttons; always true - // for the top bar (its actions are unconditional triggers). + bool enabled = true; // opposite-mode gate for bottom-bar tag buttons; always true + // for the top bar (unconditional triggers) }; -// --- Shared one-liner helpers -------------------------------------------------- - -// Modifier state at event time. Alt = the L7 replace modifier. +// Modifier state at event time. Alt = the replace modifier. inline bool ctrlDown() { return (GetAsyncKeyState(VK_CONTROL) & 0x8000) != 0; } inline bool shiftDown() { return (GetAsyncKeyState(VK_SHIFT) & 0x8000) != 0; } inline bool altDown() { return (GetAsyncKeyState(VK_MENU) & 0x8000) != 0; } @@ -515,7 +398,7 @@ inline void invalidatePanel() { if (g_panel.hwnd) InvalidateRect(g_panel.hwnd, nullptr, FALSE); } -// --- Cross-seam contract (grouped by OWNING TU; all plain free functions) ------ +// Cross-seam contract, grouped by owning TU; all plain free functions. // panel_bank_ops.cpp — book/bank accessors + the bank-CRUD verbs + menus. BankBook* book(); @@ -536,7 +419,7 @@ void showSelectionMenu(int screenX, int screenY); void showMoreMenu(); // panel_layout.cpp — toolbar/footer/menu rects, row/cluster builders, split geometry, -// region rects, the L7 display bridge. Draw and hit-test both call these so they never drift. +// region rects, the display bridge. Draw and hit-test both call these so they never drift. int modeCount(); MenuButtonRect topMenuButtonRect(int w); ActionBarRect topToolbarActionRect(int w); @@ -579,8 +462,8 @@ const Envelope& thumbnailFor(const Sample& sample, int width, const std::string& bool refreshFingerprint(); void reconcileShownBank(); -// panel_audition.cpp — the preview engine (HOT PATH: direct call-through, never -// virtual, no added header->TU indirection — T4-28 / Q-W2 guardrail). +// panel_audition.cpp — the preview engine (hot path: direct call-through, never +// virtual, no added header->TU indirection). void initPreview(); void deinitPreview(); void stopAudition(); @@ -595,7 +478,7 @@ void registerAccel(); void unregisterAccel(); // panel_drag.cpp — the card-drag/hover state machine (pure mirror: core/ui/card_drag). -// Per-mouse-move work stays plain free-function calls (T4-28). +// Per-mouse-move work stays plain free-function calls. void onMouseMove(int x, int y); void onLBtnUp(int x, int y); void handleRightClick(int x, int y); diff --git a/src/shell/panel/panel_thumbnails.cpp b/src/shell/panel/panel_thumbnails.cpp index 65bc1f5..4babe0b 100644 --- a/src/shell/panel/panel_thumbnails.cpp +++ b/src/shell/panel/panel_thumbnails.cpp @@ -1,16 +1,10 @@ -// panel_thumbnails.cpp — thumbnail compute + cache seam of the docked bank panel -// (Q-W2 split of bank_panel.cpp; M5/FA3). Owns the per-sample PCM read via PCM_source -// fed to peaks::computeEnvelope (one bin per drawn pixel column) and the in-memory -// thumbnail cache keyed by (sample id, bin width, bank generation) — plus the -// bank-change fingerprint pass that OWNS that generation key: refreshFingerprint bumps -// the generation, clears the cache, resets selection/audition, and reconciles the -// shown bank on any book mutation. (The fingerprint pass lives here rather than in -// panel_input because the cache + generation it invalidates are this seam's state — -// a Q-W2 placement judgment; the T4-01 audit lumped it under the input seam's range.) +// panel_thumbnails.cpp — thumbnail compute + cache seam of the docked bank panel. +// Owns the per-sample PCM read fed to peaks::computeEnvelope and the in-memory +// thumbnail cache keyed by (sample id, bin width, bank generation), plus the +// bank-change fingerprint pass that owns that generation key — it lives here +// because the cache + generation it invalidates are this seam's state. // -// Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.h -// WITHOUT REAPERAPI_IMPLEMENT — main.cpp owns the API pointers; here they are -// extern (CLAUDE.md §contract). DAW-verified, not unit tested. +// main.cpp owns the API pointers; here they are extern. DAW-verified, not unit tested. #include #include @@ -29,8 +23,7 @@ namespace { constexpr int kMaxThumbnailFrames = 1 << 20; // ~1M frames (~22s @ 48k) -// --- Thumbnail computation (M5; `width` is a BIN count since FA3 oversampling) -- - +// `width` is a BIN count. Envelope computeThumbnail(const std::string& absPath, int width) { if (width <= 0 || absPath.empty()) return {}; @@ -98,13 +91,11 @@ const Envelope& thumbnailFor(const Sample& sample, int width, return ins.first->second.envelope; } -// --- Bank-change detection ---------------------------------------------------- - namespace { // A fingerprint of the WHOLE BOOK: for each bank, its id + display name + active flag // + per-sample id/path. Catches every mutation the panel must redraw for: capture, -// project load, and B4's own create/rename/delete/move/activate. +// project load, and create/rename/delete/move/activate. std::string bookFingerprint() { BankBook* b = book(); if (!b) return {}; diff --git a/src/shell/panel/panel_window.cpp b/src/shell/panel/panel_window.cpp index d64a9b4..00b4ce7 100644 --- a/src/shell/panel/panel_window.cpp +++ b/src/shell/panel/panel_window.cpp @@ -1,16 +1,11 @@ -// panel_window.cpp — the window-lifecycle seam of the docked bank panel (Q-W2 split -// of bank_panel.cpp; M5 Wave A). Owns the SWELL dialog (IDD_BANK_PANEL) docked via -// DockWindowAddEx / undocked via DockWindowRemove, the dialog proc that routes -// messages to the input/drag/render/audition seams, the S8 OS drop-target opt-in -// (WM_DROPFILES -> ingest), and the shared PanelState blob's definition. +// panel_window.cpp — window-lifecycle seam of the docked bank panel. Owns the SWELL +// dialog (docked via DockWindowAddEx / undocked via DockWindowRemove), the dialog +// proc routing to the input/drag/render/audition seams, the OS drop-target opt-in +// (WM_DROPFILES -> ingest), and the shared PanelState blob's definition. The panel +// never inserts into the arrange. Dock title + persisted-position identstr both +// come from app_version (channel-qualified). // -// READ-ONLY of the TIMELINE (load-bearing principle): the panel never inserts into -// the arrange. Channel-qualified dock identity (Phase V, V4): title + persisted- -// position identstr both come from app_version. -// -// Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.h -// WITHOUT REAPERAPI_IMPLEMENT — main.cpp owns the API pointers; here they are -// extern (CLAUDE.md §contract). DAW-verified, not unit tested. +// main.cpp owns the API pointers; here they are extern. DAW-verified, not unit tested. #include #include @@ -18,12 +13,12 @@ #include "shell/panel/panel_state.h" #include "shell/panel/panel_window.h" -#include "shell/panel/draw_kit.h" // kitFontsInit/Shutdown — the kit's cached AA fonts (L1) -#include "ingest.h" // ingestDroppedFiles — S8 drop-onto-panel ingest +#include "shell/panel/draw_kit.h" +#include "ingest.h" #ifdef _WIN32 #include // GET_X_LPARAM / GET_Y_LPARAM (SWELL supplies them on mac/linux) -#include // DragAcceptFiles / DragQueryFile / DragFinish — S8 drop ingest +#include // DragAcceptFiles / DragQueryFile / DragFinish #endif #include "resource.h" @@ -40,26 +35,20 @@ extern REAPER_PLUGIN_HINSTANCE g_hInst; namespace reasampler::panel { -// The one shared panel state blob (declared extern in panel_state.h). Defined here — -// the lifecycle seam owns the state's lifetime, mirroring the old single-TU global. +// Defined here — the lifecycle seam owns the state's lifetime. PanelState g_panel; -// --- Dialog proc + docking ---------------------------------------------------- - namespace { -// Decodes a WM_DROPFILES HDROP into the dropped file paths (absolute, OS-native) and hands -// them to the S8 ingest path. Multi-file drop: ingestDroppedFiles imports all into the active -// bank (bank-fill only — no assignment to any live instance). Always DragFinish's the HDROP -// (frees the shell-allocated drop buffer) on every path. DragQueryFile(hDrop, 0xFFFFFFFF, ...) -// returns the file count; then each path is queried by index. Both Win32 and SWELL expose -// DragQueryFile/DragFinish with this contract. +// DragQueryFile(hDrop, 0xFFFFFFFF, ...) returns the file count; each path is then +// queried by index (length first, excludes NUL, then a sized buffer). DragFinish +// always frees the shell-allocated drop buffer. Multi-file drop imports all into +// the active bank (bank-fill only — no assignment to any live instance). void handleDropFiles(HDROP hDrop) { std::vector paths; const UINT count = DragQueryFile(hDrop, 0xFFFFFFFF, nullptr, 0); paths.reserve(count); for (UINT i = 0; i < count; ++i) { - // Query the required length first (excludes the NUL), then read into a sized buffer. const UINT len = DragQueryFile(hDrop, i, nullptr, 0); if (len == 0) continue; std::vector buf(static_cast(len) + 1, '\0'); @@ -74,8 +63,6 @@ void handleDropFiles(HDROP hDrop) { WDL_DLGRET dlgProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) { switch (msg) { case WM_DROPFILES: - // S8 drop-onto-panel ingest: OS file drop on the docked panel HWND -> import - // into the active bank (bank-fill only). wParam is the HDROP. handleDropFiles(reinterpret_cast(wParam)); return 0; case WM_PAINT: { @@ -101,10 +88,8 @@ WDL_DLGRET dlgProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) { handleRightClick(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam)); return 0; case WM_CAPTURECHANGED: - // Capture lost (pointer left window pre-threshold and released outside, or another - // window stole capture mid-drag) — cancel the whole drag as a NO-OP so no stale - // state lingers, mirroring onLBtnUp's reset (peer-path symmetry). Nothing is - // mutated on a cancel; the cursor is restored to the arrow. + // Capture lost (pointer left pre-threshold, or another window stole it + // mid-drag) — cancel the drag as a no-op, mirroring onLBtnUp's reset. if (g_panel.dragArmed || g_panel.dragging) { resetDragState(); SetCursor(LoadCursor(nullptr, IDC_ARROW)); @@ -112,13 +97,9 @@ WDL_DLGRET dlgProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) { } 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. + // Unlike button messages, WM_MOUSEWHEEL carries SCREEN coords in lParam + // (Win32 and SWELL agree), so convert to client space first. Wheel delta + // is the HIWORD of wParam. Consume (return 1) only when the footer acts. POINT pt{GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam)}; ScreenToClient(hwnd, &pt); const int delta = static_cast(HIWORD(wParam)); @@ -147,30 +128,23 @@ void openPanel() { } initPreview(); - // Create the kit's cached AA fonts before the first paint (Phase L, L1). Idempotent, so - // a reopen after closePanel (which leaves the fonts alive) is a cheap no-op; the fonts - // are torn down once at bankPanelShutdown. All panel text draws through these. + // Idempotent — a reopen after closePanel (fonts left alive) is a cheap no-op; + // torn down once at bankPanelShutdown. kitFontsInit(); g_panel.hwnd = CreateDialogParam(g_hInst, MAKEINTRESOURCE(IDD_BANK_PANEL), GetMainHwnd(), dlgProc, 0); if (!g_panel.hwnd) return; - // Channel-qualified dock identity (Phase V, V4). The title and the persisted-position - // identstr both come from app_version, so a beta panel is distinguishable ("ReaSampler - // Bank beta") and does not fight over stable's saved dock slot (the identstr is a - // REAPER-global collision surface — it keys the persisted dock position). + // identstr is a REAPER-global collision surface keying the persisted dock + // position — channel-qualified so beta doesn't fight over stable's slot. DockWindowAddEx(g_panel.hwnd, dockTitle().c_str(), dockIdent().c_str(), true); DockWindowActivate(g_panel.hwnd); g_panel.open = true; - // S8: accept OS file drops on the panel HWND (WM_DROPFILES routes to handleDropFiles). - // DragAcceptFiles is a native Win32 shell call (shellapi.h); SWELL does NOT expose it, - // so the opt-in is Windows-only here. The primary/shipped platform is Windows (the VST3 - // instrument the drop assigns to is Windows-only, D5); a mac/linux drop-registration - // surface is out of scope for this dispatch. WM_DROPFILES handling itself uses - // DragQueryFile/DragFinish, which SWELL DOES provide, so a drop delivered by other means - // would still ingest — only the accept opt-in is gated. + // DragAcceptFiles is native Win32 (shellapi.h); SWELL doesn't expose it, so the + // accept opt-in is Windows-only. DragQueryFile/DragFinish ARE SWELL-provided, + // so a drop delivered by other means would still ingest. #ifdef _WIN32 DragAcceptFiles(g_panel.hwnd, TRUE); #endif @@ -199,27 +173,20 @@ void closePanel() { } // namespace reasampler::panel -// --- Public API (the lifecycle seam — panel_window.h) -------------------------- - namespace reasampler { void bankPanelInit(ReaSamplerSession* session) { panel::g_panel.session = session; } -// Returns true only when the panel window is actually visible to the user right now. -// IsWindowVisible() returns false when the docker is hidden via Alt+D even though the -// HWND and g_panel.open are still live — the live query is the source of truth for -// toggle decisions and the Actions-list checkmark (OnToggleAction in main.cpp). +// Alt+D hides the docker without destroying the window, leaving HWND/g_panel.open +// live but IsWindowVisible false — the live query is the source of truth for +// toggle decisions and the Actions-list checkmark. static bool panelEffectivelyVisible() { return panel::g_panel.hwnd && IsWindowVisible(panel::g_panel.hwnd); } void bankPanelToggle() { - // Decide from live visibility, not the cached g_panel.open flag. - // Alt+D hides the docker without destroying the window, leaving g_panel.open - // stale (true) while the panel is gone. Using IsWindowVisible avoids the - // double-fire needed to re-show the panel after a docker hide. if (panelEffectivelyVisible()) panel::closePanel(); else @@ -227,8 +194,6 @@ void bankPanelToggle() { } bool bankPanelIsOpen() { - // Derive from live window state so the Actions-list checkmark stays honest - // even after Alt+D hides the docker without notifying the extension. return panelEffectivelyVisible(); } @@ -239,7 +204,7 @@ void bankPanelInvalidate() { void bankPanelShutdown() { panel::closePanel(); panel::deinitPreview(); - kitFontsShutdown(); // free the kit's cached AA fonts + their owned HFONTs (L1) + kitFontsShutdown(); panel::g_panel.cache.clear(); panel::g_panel.session = nullptr; } diff --git a/src/shell/panel/panel_window.h b/src/shell/panel/panel_window.h index 654727c..1143aa3 100644 --- a/src/shell/panel/panel_window.h +++ b/src/shell/panel/panel_window.h @@ -1,42 +1,29 @@ #pragma once -// panel_window — the window-lifecycle seam of the docked bank panel (Q-W2 split of -// bank_panel.h; M5, Wave A). REAPER-facing shell: the .cpp owns a SWELL dialog -// (IDD_BANK_PANEL) docked via DockWindowAddEx / undocked via DockWindowRemove, -// toggled open/closed, plus the OS drop-target opt-in (S8) and the dialog proc that -// routes messages to the input/drag/render seams. The panel itself NEVER inserts -// into the arrange or mutates the project (CONTEXT.md §load-bearing principle). -// -// The header is REAPER-free: main.cpp drives the panel through these free functions, -// passing the live session so the panel reads the current bank. All SWELL / LICE / -// PCM_source use is confined to the shell/panel/ .cpp seams. +// panel_window — window-lifecycle seam of the docked bank panel: owns the SWELL +// dialog (dock/undock, toggle), the drop-target opt-in, and the dialog proc that +// routes to the input/drag/render seams. Panel never inserts into the arrange or +// mutates the project. Header is REAPER-free; main.cpp drives it via these +// free functions. namespace reasampler { class ReaSamplerSession; -// Wires the panel into main.cpp's lifecycle. Called once after the API pointers -// are loaded, BEFORE the toggle action is registered. `session` must outlive the -// panel (it is the extension-lifetime g_session). Stores the session pointer the -// panel reads on every repaint; does not create the window yet. +// `session` must outlive the panel (extension-lifetime g_session). Call once +// after API pointers load, before the toggle action registers. void bankPanelInit(ReaSamplerSession* session); -// Toggles the docked window: creates+docks it if hidden, hides+undocks it if -// shown. Bound to the "toggle bank panel" action. Safe to call before the first -// timer tick. +// Creates+docks if hidden, hides+undocks if shown. void bankPanelToggle(); -// Whether the panel window is currently open/visible. Feeds the action's -// checked-state (toggleaction) so REAPER shows a tick next to the menu entry. +// Feeds the toggle action's checked-state. bool bankPanelIsOpen(); -// Requests an immediate repaint of the panel if it is open. A no-op when the panel -// is closed (safe to call unconditionally). Called by the actions layer after a -// mode change so the footer [Arrange|Design] toggle reflects the new mode without -// requiring a hide/reshow. +// No-op if closed. Called after a mode change so the footer reflects it without +// a hide/reshow. void bankPanelInvalidate(); -// Tears the panel down on extension unload: destroys the window and releases any -// cached thumbnails / PCM handles. Mirror of bankPanelInit; safe if never opened. +// Mirror of bankPanelInit; safe if never opened. void bankPanelShutdown(); } // namespace reasampler diff --git a/src/shell/persist/ext_state_io.cpp b/src/shell/persist/ext_state_io.cpp index 3f7d92d..af0bae5 100644 --- a/src/shell/persist/ext_state_io.cpp +++ b/src/shell/persist/ext_state_io.cpp @@ -1,26 +1,22 @@ -// ext_state_io.cpp — the ext-state ↔ JSON serialization half of the persist seam -// (Q-W5 split of the former persist.cpp; see session.h for the TU map and -// ext_state_io.h for the key contract): the session's save/load/assignment-request -// bridge, plus the shared persist_detail helpers (active-project read, growing -// ext-state read, GUID minting, bank-folder relocation) the sibling TUs call. +// ext_state_io.cpp — the ext-state <-> JSON serialization half of the persist +// seam (see session.h for the TU map, ext_state_io.h for the key contract): +// the session's save/load/assignment-request bridge, plus the shared +// persist_detail helpers the sibling TUs call. // -// Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.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; here they are extern (CLAUDE.md §contract). // -// Storage: SetProjExtState / GetProjExtState, namespace "reasampler". Phase B: the -// whole BankBook (pool as bank-zero + named banks) is written under key "banks" -// (authoritative); the legacy single-bank key "bank_index" is RETIRED — cleared on -// save (SetProjExtState with "" deletes it) and read only once, to migrate a pre- -// multi-bank project's index into the pool. Ext state is stored INSIDE the .rpp, so -// the banks travel with the project automatically (CONTEXT.md §Persistence & paths). -// The only thing that does NOT travel for free is the physical bank folder; on -// Save-As to a new directory we relocate it so the indices' relative paths still -// resolve (poll(), session.cpp, executes the relocation this TU implements). +// Storage: SetProjExtState/GetProjExtState, namespace "reasampler". The whole +// BankBook (pool as bank-zero + named banks) is written under key "banks" +// (authoritative); the legacy single-bank key "bank_index" is retired — +// cleared on save, read only once to migrate a pre-multi-bank project into +// the pool. Ext state is stored inside the .rpp, so the banks travel with the +// project automatically; the physical bank folder does not, so a Save-As to a +// new directory relocates it (poll(), session.cpp). // -// NON-DESTRUCTIVE: this module writes ONLY our own ext-state key and moves ONLY -// our own reasampler_bank/ folder. It never touches the user's media, items, or -// other ext-state namespaces. +// Non-destructive: this module writes only our own ext-state keys and moves +// only our own reasampler_bank/ folder. #include "shell/persist/ext_state_io.h" @@ -53,11 +49,9 @@ namespace reasampler::persist_detail { namespace fs = std::filesystem; -// Read the active project pointer and its .rpp path in one shot. idx=-1 is the -// current project tab (SDK header line ~1262). The out-buffer receives the full -// .rpp path, EMPTY for a never-saved project (the reliable unsaved sentinel — -// same fact capture.cpp relies on). Returns nullptr proj only when there is no -// active project at all. +// idx=-1 is the current project tab. rppPathOut is empty for a never-saved +// project (the reliable unsaved sentinel); returns nullptr only with no active +// project at all. void* readActiveProject(std::string& rppPathOut) { std::vector buf(4096, '\0'); ReaProject* proj = EnumProjects(-1, buf.data(), static_cast(buf.size())); @@ -65,24 +59,13 @@ void* readActiveProject(std::string& rppPathOut) { return proj; } -// Parent directory of the .rpp, forward-slashed, no trailing slash. Empty in -> -// empty out. Mirrors capture.cpp's derivation so the bank sits alongside the -// .rpp (NOT GetProjectPathEx, which returns the recording path — see capture.cpp -// for the full rationale). The derivation itself is projectDirOfRpp in capture_paths -// (pure) — the SAME convention the VST3 instrument resolves audio paths by, so both -// artifacts share one implementation rather than duplicating the parent-of-.rpp step. +// NOT GetProjectPathEx, which returns the recording path, not the .rpp's own +// directory. Delegates to the pure projectDirOfRpp so both artifacts share one +// implementation. std::string projectDirOf(const std::string& rppPath) { return capture::projectDirOfRpp(rppPath); } -// GetProjExtState needs a caller-supplied buffer; the index JSON can be large -// (many samples). The grow-until-strict-fit retry policy is the SHARED pure -// wire::readProjExtStateGrowing (T2-04 — the same policy the usage_scan and -// VST-bridge reads run); this wrapper binds the REAPER call and -// folds the terminal cases persist's callers expect: "" for an absent key (a valid -// empty bank, not an error) and a console warning + "" for a value exceeding the -// 16 MB ceiling, so an over-large value reads as "too large to load", not silent -// data loss (mirrors the malformed-JSON warning in loadFromProject). std::string getProjExtStateString(void* proj, const char* ns, const char* key) { using wire::GrowingExtStateRead; const GrowingExtStateRead read = wire::readProjExtStateGrowing( @@ -103,8 +86,7 @@ std::string getProjExtStateString(void* proj, const char* ns, const char* key) { return {}; } -// The GUID we mint per project, formatted "{XXXXXXXX-....}" by guidToString. -// guidToString wants a >=64-char destination (SDK header line ~3846). +// guidToString wants a >=64-char destination. std::string genProjectGuidString() { GUID g{}; genGuid(&g); @@ -113,13 +95,8 @@ std::string genProjectGuidString() { return std::string(buf); } -// Ensure a SAVED project carries a stored GUID, minting and writing one if it -// has none yet (a project saved before this feature shipped, or a brand-new -// first save). Returns the effective GUID: the existing one, the freshly minted -// one, or "" for an unsaved project (no .rpp to store ext state into — the same -// gate SetProjExtState/saveToActiveProject already respect on empty path). -// Called from BOTH prime and the Load branch so identity is established the same -// way on every entry to a project (peer-symmetry: no path skips the mint). +// Returns the existing GUID, a freshly minted one, or "" for an unsaved +// project. Called from both prime and the Load branch so no path skips the mint. std::string ensureProjectGuid(void* proj, const std::string& rppPath, const std::string& currentGuid) { if (!proj || rppPath.empty()) return {}; // unsaved -> cannot store a GUID @@ -130,11 +107,9 @@ std::string ensureProjectGuid(void* proj, const std::string& rppPath, return minted; } -// Copy the bank folder from oldDir to newDir, non-destructively (copy, do not -// move — see the handoff for the copy-vs-move rationale). Overwrites existing -// files at the destination so a re-save is idempotent. Best-effort: filesystem -// errors are swallowed and reported to the console rather than thrown across the -// REAPER boundary. Returns true if the copy ran (source existed). +// Copy, not move (non-destructive); overwrites existing files at the +// destination so a re-save is idempotent. Best-effort: filesystem errors are +// swallowed and reported to the console. Returns true if the copy ran. bool relocateBankFolder(const std::string& oldBankDir, const std::string& newBankDir) { std::error_code ec; @@ -168,58 +143,37 @@ bool ReaSamplerSession::saveToActiveProject() { if (!proj) return false; // no active project — nothing to persist if (rppPath.empty()) return false; // unsaved project — no .rpp to store into - // Phase B: the whole book (pool as bank-zero + named banks) is authoritative and - // rides in the `banks` key. const std::string banksJson = book_.serialize(); SetProjExtState(static_cast(proj), projExtNamespace(), kProjExtBanksKey, banksJson.c_str()); - // Retire the legacy single-bank `bank_index` key: SetProjExtState with an empty - // value DELETES the key (SDK header ~6288: val NULL or "" deletes the data). This - // realizes retirement concretely — after any save, a formerly-legacy project - // carries `banks` and NO `bank_index`, and going forward the legacy key is never - // written. Cheap and idempotent when the key is already absent. + // Retire the legacy single-bank key: SetProjExtState with an empty value + // deletes it. Idempotent when already absent. SetProjExtState(static_cast(proj), projExtNamespace(), kProjExtIndexKey, ""); - // Additive: the Design-View model rides alongside the banks in its own key. - // Independent write — does not disturb the `banks` blob above. + // Each of the following rides in its own key, independent of `banks`. const std::string viewJson = view_.serialize(); SetProjExtState(static_cast(proj), projExtNamespace(), 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 = capture::serializeTailSetting(tail_); SetProjExtState(static_cast(proj), projExtNamespace(), kProjExtTailKey, tailJson.c_str()); - // Additive: the owned-file manifest (Phase B B-cap) rides alongside in its own - // `owned_files` key. Independent write — does not disturb the blobs above. Written - // on EVERY save so a capture's manifest record survives Save / Save-As / reopen, - // and so the manifest and the bank stay in lockstep on disk (both persisted by the - // same saveToActiveProject the capture add-path calls). Uses the channel-derived - // namespace (projExtNamespace) like its sibling keys — V4 isolation applies here too. + // Written on every save so the manifest and the bank stay in lockstep on disk. const std::string ownedJson = owned_.serialize(); SetProjExtState(static_cast(proj), projExtNamespace(), kProjExtOwnedKey, ownedJson.c_str()); - // Phase V (V1/V4): stamp the WRITING version — the build producing this save — under - // the version key, on the SAME seam as the keys above so the stamp and MarkProjectDirty - // stay paired (no drifting ad-hoc SetProjExtState). stampVersion() (NOT appVersion()) is - // the NUMERIC TRIPLE ONLY on both channels — no "-beta" suffix — so the stamp parses as - // Stamped on read-back and stays byte-identical to stable regardless of channel; the - // channel is already carried by the isolated namespace (projExtNamespace) this writes to. + // stampVersion() (not appVersion()) is the numeric triple only, no "-beta" + // suffix, so the stamp is byte-identical to stable regardless of channel + // — the channel is already carried by the isolated namespace. SetProjExtState(static_cast(proj), projExtNamespace(), kProjExtVersionKey, version::stampVersion().c_str()); - // S9: stamp the current bank-generation counter under its own wire-shared key, on the SAME - // seam so the counter and MarkProjectDirty stay paired. The value is whatever - // bumpBankGeneration() advanced it to since the last save (0 if never bumped / pre-S9), so - // every content mutation's own save carries the fresh generation the instrument reads. The - // format is the SHARED pure encoder (instrument::map::formatBankGeneration) so writer and reader agree - // byte-for-byte — a decimal integer. Additive: does not disturb the blobs above. + // Whatever bumpBankGeneration() advanced the counter to since the last + // save (0 if never bumped). Shared encoder so writer/reader agree byte-for-byte. SetProjExtState(static_cast(proj), projExtNamespace(), kProjExtBankGenKey, instrument::map::formatBankGeneration(bankGeneration_).c_str()); @@ -234,11 +188,8 @@ bool ReaSamplerSession::writeAssignmentRequest(const std::string& wire) { if (!proj) return false; // no active project — nothing to signal if (rppPath.empty()) return false; // unsaved project — no .rpp to store into - // One-shot write of the ingest assignment request under its own key (S8). Independent - // of the book/view/tail blobs — this is a transient signal to the instrument, not - // session state that must ride every save. Uses the channel-derived namespace - // (projExtNamespace) like every sibling key — V4 isolation applies here too, so a beta - // instrument reads only a beta extension's assignment requests. + // One-shot write under its own key: a transient signal to the instrument, + // not session state that rides every save. SetProjExtState(static_cast(proj), projExtNamespace(), kProjExtAssignKey, wire.c_str()); MarkProjectDirty(static_cast(proj)); @@ -247,12 +198,8 @@ bool ReaSamplerSession::writeAssignmentRequest(const std::string& wire) { namespace { -// Load the Design-View model from a project's view_state key, or return a fresh -// default. An absent/empty key (older project with no view state) yields a -// default-constructed model (Arrange + Design seeded, active = Arrange) — graceful, -// never a crash. Malformed JSON is warned and also falls back to default, mirroring -// the bank's malformed-index handling. The whole model round-trips: modes, -// membership, show-both, snapshots, and active mode all ride inside the one blob. +// Absent/empty key -> default-constructed model, graceful, never a crash. +// Malformed JSON is warned and also falls back to default. ViewModeModel loadViewModel(ReaProject* proj) { if (!proj) return ViewModeModel{}; const std::string viewJson = @@ -266,10 +213,8 @@ 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. +// Absent/empty key -> default (None / 2 s manual). Malformed JSON warns and +// falls back to default. capture::TailSetting loadTailSetting(ReaProject* proj) { if (!proj) return capture::TailSetting{}; const std::string tailJson = @@ -284,12 +229,9 @@ capture::TailSetting loadTailSetting(ReaProject* proj) { return *loaded; } -// Load the owned-file manifest from a project's owned_files key, or return an empty -// manifest. An absent/empty key (older / never-captured project) yields an empty -// manifest — graceful, never a crash. Malformed JSON is warned and also falls back to -// empty, mirroring the bank's / view's / tail's malformed handling. Phase R prune then -// sees an empty ownership record and (safely) attributes nothing until the next capture -// rebuilds it — losing the record degrades safety, never correctness. +// Absent/empty key -> empty manifest. Malformed JSON warns and falls back to +// empty; prune then attributes nothing until the next capture rebuilds it — +// degrades safety, never correctness. model::OwnedFileManifest loadOwnedManifest(ReaProject* proj) { if (!proj) return model::OwnedFileManifest{}; const std::string ownedJson = @@ -307,45 +249,28 @@ model::OwnedFileManifest loadOwnedManifest(ReaProject* proj) { } // namespace void ReaSamplerSession::loadFromProject(void* proj, const std::string& projectDir) { - // Raise the load signal for the D4 reapply-on-open glue. loadFromProject is the - // single choke point for every load path (prime, project switch/open, forked- - // sibling load), so setting it here — and NOT on the Save-As branch, which keeps - // the in-memory model as-is — makes the signal fire exactly when a fresh view - // model has been installed and its active mode's visibility needs reapplying. - // main.cpp drains it via consumeLoadSignal() on the same tick. + // loadFromProject is the single choke point for every load path (prime, + // project switch/open, forked-sibling load) — NOT the Save-As branch, + // which keeps the in-memory model as-is. main.cpp drains this via + // consumeLoadSignal() on the same tick. loadPending_ = true; - // The view model is restored on EVERY load path (peer-symmetry with the bank - // reset below): switching to a project with no view state must clear stale - // in-memory state, not inherit the previous project's. D3 restores MODEL STATE - // only — no visibility/processing is applied here (that is D4). + // view_/tail_/owned_ are all restored on EVERY load path: switching to a + // project with no stored state must reset to default, never inherit the + // previous project's. An undo/redo reload must re-read the restored + // values so they match the rolled-back state. view_ = loadViewModel(static_cast(proj)); - - // The tail setting is restored on EVERY load path too (peer-symmetry): switching - // to a project with no stored setting must fall back to the default, not inherit - // the previous project's choice (this REPLACES the old session-carry behavior). tail_ = loadTailSetting(static_cast(proj)); - - // The owned-file manifest is restored on EVERY load path too (peer-symmetry with the - // bank/view/tail resets): switching to a project with no stored manifest must reset - // to empty, not inherit the previous project's ownership record; an undo/redo reload - // (R-B) must re-read the restored manifest so it matches the rolled-back bank state. owned_ = loadOwnedManifest(static_cast(proj)); - // Phase V (V1): recover the writing-version stamp on EVERY load path (peer-symmetry - // with tail_/view_ above). An absent stamp classifies as PreVersioning, a malformed - // one as Unknown — both silent, no console warning (a pre-versioning project is not - // an error). getProjExtStateString returns "" for an absent key, which is exactly the - // PreVersioning input classifyWritingVersion expects. proj == nullptr -> "" -> default. + // An absent stamp classifies as PreVersioning, a malformed one as Unknown + // — both silent. proj == nullptr -> "" -> default. writingVersion_ = version::classifyWritingVersion( proj ? getProjExtStateString(proj, projExtNamespace(), kProjExtVersionKey) : std::string{}); - // S9: recover the bank-generation counter on EVERY load path (peer-symmetry with - // writingVersion_/tail_/view_ above), so it continues monotonic from the stored value - // rather than resetting to 0 on reopen — a next bump then reads > the stored value. A - // project switch reads THAT project's counter, not the previous one's; an absent/malformed - // stamp (pre-S9 or corrupt) parses to 0 via the SHARED decoder. proj == nullptr -> 0. + // Continues monotonic from the stored value rather than resetting to 0 on + // reopen; absent/malformed parses to 0 via the shared decoder. bankGeneration_ = instrument::map::parseBankGeneration( proj ? getProjExtStateString(proj, projExtNamespace(), kProjExtBankGenKey) : std::string{}); @@ -355,14 +280,9 @@ void ReaSamplerSession::loadFromProject(void* proj, const std::string& projectDi return; } - // Read both possible sources: the authoritative `banks` blob and the retired-but- - // possibly-still-present legacy `bank_index`. The precedence + migration decision - // (`banks` wins; else the legacy index migrates into the pool; else an empty book) - // is pure logic; it is inlined here rather than via BankBook::loadFromPersisted only - // so a malformed `banks` blob can be warned on the console (single parse) — a corrupt - // blob must read as "ignored", not silent loss, mirroring the prior malformed-index - // warning. A malformed `banks` degrades to an empty book and does NOT fall back to - // the stale legacy key (which would resurrect superseded single-bank state). + // `banks` is authoritative when present; a malformed blob degrades to an + // empty book rather than falling back to the stale legacy key (which + // would resurrect superseded single-bank state). const std::string banksJson = getProjExtStateString(proj, projExtNamespace(), kProjExtBanksKey); if (!banksJson.empty()) { @@ -374,29 +294,18 @@ void ReaSamplerSession::loadFromProject(void* proj, const std::string& projectDi book_ = std::move(*loaded); } } else { - // No `banks` yet — fall back to the legacy `bank_index`, migrated into the pool - // by BankBook's parse-time promotion. loadFromPersisted covers the legacy-or- - // empty tail; passing "" for banksJson takes exactly that branch. + // No `banks` yet — migrate the legacy `bank_index` into the pool. const std::string legacyJson = getProjExtStateString(proj, projExtNamespace(), kProjExtIndexKey); book_ = BankBook::loadFromPersisted(std::string{}, legacyJson); } - // L7 slot migration: seed every bank's display-position SlotMap from its index - // insertion order when the loaded blob carried none (a pre-L7 project -> dense, - // gap-free, visually identical on first post-L7 load), and reconcile a partial map - // (drop stale markers, append unmapped samples) for a blob written by an earlier L7 - // build. One-way: once the book is re-saved the reconciled slot data is authoritative. - // Idempotent, so a fresh empty book is a cheap no-op. + // Seed each bank's display-position SlotMap from insertion order when the + // loaded blob carried none, and reconcile a partial map. Idempotent. book_.reconcileSlots(); - // Project-relative resolution is a READ-time concern: every BankModel in the book - // stores only relative paths (invariant, enforced per-bank at add()), and consumers - // (M5 panel, M6 insert) resolve each entry against the CURRENT project dir via - // resolveBankFile(projectDir, relativePath). We do NOT rewrite stored paths to - // absolute here — that would break the relative-only invariant and travel-with-.rpp. - // projectDir is threaded through for those consumers; nothing to do at load time - // beyond replacing the in-memory book. + // Paths stay relative (read-time resolution is the consumers' job); + // nothing to do here beyond replacing the in-memory book. (void)projectDir; } diff --git a/src/shell/persist/ext_state_io.h b/src/shell/persist/ext_state_io.h index ab5a389..4f6f9be 100644 --- a/src/shell/persist/ext_state_io.h +++ b/src/shell/persist/ext_state_io.h @@ -1,59 +1,40 @@ #pragma once -// ext_state_io — the ext-state ↔ JSON serialization half of the persist seam -// (Q-W5 split of the former persist god-TU; session.h holds the ReaSamplerSession -// lifecycle, prune_fs.cpp the prune scan + the single file-deletion authority). +// ext_state_io — the ext-state <-> JSON serialization half of the persist seam. // This header owns the persist-side key spellings and the channel-derived -// namespace accessor; the TU (ext_state_io.cpp) implements the session's -// save/load/assignment-request bridge plus the GUID minting and bank-folder -// relocation helpers the poll executes. +// namespace accessor; ext_state_io.cpp implements the session's save/load/ +// assignment-request bridge plus GUID minting and bank-folder relocation. // -// The ext-state namespace + the WIRE-SHARED key names are the contract between this -// extension (writer) and the VST3 instrument (reader), so they live in ext_keys.h -// (pure, REAPER-free) and are included here — not duplicated. The namespace is -// CHANNEL-DERIVED (Phase V, V4): ext_keys.h's kProjExtNamespace / this projExtNamespace() -// both delegate to app_version's extStateNamespace() — "reasampler" on stable (byte- -// identical to the pre-V4 build) or "reasampler_beta" on the isolated beta build. Both -// artifacts read the ONE app_version symbol, so the instrument reads exactly the namespace -// the extension writes, per channel. Beta reads/writes ONLY its own namespace — a project -// saved by stable shows empty/default state in beta and vice versa; that isolation is the -// accepted V4 safety property (no cross-namespace read, migration, or fallback), not a bug. -// The per-key semantics persist relies on (spellings owned by ext_keys.h): -// * kProjExtBanksKey : the whole serialized BankBook (pool + named banks). -// AUTHORITATIVE going forward; the VST reads this key to see the live bank. -// * kProjExtIndexKey : RETIRED legacy single-bank key. No longer WRITTEN (cleared -// on save); READ once on load to migrate a legacy project into the pool. -// * kProjExtViewKey : the Design-View ViewModeModel JSON. -// * kProjExtTailKey : the docked panel's TailSetting JSON. -// * kProjExtGuidKey : the per-project minted GUID (content-based identity; poll() -// tells a Save-As from a recycled-pointer project switch by it). -// All are FOREVER-STABLE once shipped: changing any strands every already-saved -// project's stored state under that key. +// The namespace + wire-shared key names are the contract with the VST3 +// instrument; they live in ext_keys.h (pure, REAPER-free), included here, not +// duplicated. Channel-derived: "reasampler" on stable, "reasampler_beta" on +// beta — a project saved by stable shows empty/default state in beta and vice +// versa; that isolation is deliberate. +// +// Per-key semantics (spellings owned by ext_keys.h): kProjExtBanksKey is the +// whole serialized BankBook, authoritative; kProjExtIndexKey is the retired +// legacy single-bank key (read once to migrate); kProjExtViewKey/TailKey are +// the Design-View and tail JSON; kProjExtGuidKey is the per-project minted +// GUID poll() uses to tell Save-As from a recycled-pointer switch. All are +// FOREVER-STABLE once shipped. #include "core/version/app_version.h" #include "ext_keys.h" namespace reasampler { -// The accessor form of the namespace: ext_keys.h's kProjExtNamespace is the value; this -// is the const char* the SetProjExtState/GetProjExtState calls pass. Kept as an -// accessor (not a literal) because the string is channel-derived at build time. +// Accessor, not a literal, because the string is channel-derived at build time. inline const char* projExtNamespace() { return version::extStateNamespace().c_str(); } -// The two EXTENSION-ONLY keys — NOT part of the VST wire contract (the instrument -// reads only banks/view/tail/guid), so they stay here rather than in ext_keys.h: -// -// owned_files — the owned-file manifest JSON (project-relative files the capture path -// itself created; Phase B B-cap seam, consumed by Phase R prune to tell the bank system's -// own orphans from hand-dropped files). A SIBLING key alongside banks/view/tail — NOT -// folded into `banks`, so it stays decoupled from membership. FOREVER-STABLE: changing it -// strands every saved project's ownership record (prune falls back to an empty manifest — -// graceful, but the attribution safety net is lost until the next capture rebuilds it). +// Extension-only keys — not part of the VST wire contract, so they live here +// rather than in ext_keys.h. + +// Project-relative files the capture path itself created, consumed by prune +// to tell the bank system's own orphans from hand-dropped files. A sibling +// key, not folded into `banks`. FOREVER-STABLE. inline constexpr const char* kProjExtOwnedKey = "owned_files"; -// version — the ReaSampler version that last WROTE this project (Phase V, V1). Written on -// every save, so every saved .rpp records which build produced its state — the seam a -// future within-channel forward migration keys off. An absent key is the explicit -// pre-versioning case, read silently, never an error. FOREVER-STABLE key string. +// The ReaSampler version that last wrote this project. An absent key is the +// pre-versioning case, read silently. FOREVER-STABLE. inline constexpr const char* kProjExtVersionKey = "version"; } // namespace reasampler diff --git a/src/shell/persist/persist_internal.h b/src/shell/persist/persist_internal.h index f574387..c98345a 100644 --- a/src/shell/persist/persist_internal.h +++ b/src/shell/persist/persist_internal.h @@ -1,14 +1,9 @@ -// persist_internal.h — INTERNAL shared helpers for the persist TU family (Q-W5: -// session / ext_state_io / prune_fs, split out of the former persist.cpp god-TU). -// Included ONLY by those three TUs — never a public seam (mirror of the panel's -// panel_state.h / the editor's editor_internal.h internal-seam precedent). Holds the -// former anonymous-namespace helpers that more than one split TU needs; every -// definition lives in ext_state_io.cpp (they are all ext-state / GUID / path / folder -// machinery). Behavior-identical to the pre-split definitions. +// persist_internal.h — internal shared helpers for the persist TU family +// (session / ext_state_io / prune_fs). Included only by those three TUs, never +// a public seam. Every definition lives in ext_state_io.cpp. // -// REAPER-FREE HEADER: the project handle crosses this seam as the same opaque void* -// the public session header already uses, so no SDK type leaks; the .cpps cast at -// the API boundary. +// REAPER-free header: the project handle crosses this seam as the same opaque +// void* the public session header uses, so no SDK type leaks. #pragma once @@ -29,8 +24,7 @@ std::string projectDirOf(const std::string& rppPath); // Growing GetProjExtState read for `key` in namespace `ns` against `proj`. Returns // "" when the key is absent (a valid empty bank, not an error) and warns on the // console for a value exceeding the 16 MB read ceiling (unreadable whole, ignored). -// The retry policy itself is the shared pure wire::readProjExtStateGrowing -// (T2-04; rehomed to core/wire in Q-W6); this wrapper binds the REAPER call + persist's fold. +// Binds wire::readProjExtStateGrowing (the shared retry policy) to the REAPER call. std::string getProjExtStateString(void* proj, const char* ns, const char* key); // The GUID we mint per project, formatted "{XXXXXXXX-....}" by guidToString. diff --git a/src/shell/persist/prune_fs.cpp b/src/shell/persist/prune_fs.cpp index f5c7f27..783164b 100644 --- a/src/shell/persist/prune_fs.cpp +++ b/src/shell/persist/prune_fs.cpp @@ -1,21 +1,17 @@ -// prune_fs.cpp — the prune scan + THE SINGLE FILE-DELETION AUTHORITY in ReaSampler -// (Q-W5 split of the former persist.cpp; see session.h for the TU map). +// prune_fs.cpp — the prune scan + THE SINGLE FILE-DELETION AUTHORITY in ReaSampler. // -// deleteOrphanFile below (SHFileOperationW on Windows, std::filesystem::remove on -// SWELL platforms) is the ONLY code in the system that deletes USER files — the sole -// deletion authority over the bank folder's bytes (the R3 prune; shells removing a -// transient scratch file they themselves just created, e.g. the drop path's temp -// .vstpreset, are self-cleanup, not authority over user data). It is deliberately -// file-local (anonymous namespace): nothing outside this TU can reach it. The Q-W5 -// split CONCENTRATES the deletion authority here — it must never -// spread (CONTEXT.md §Phase Q deletion-authority isolation; -// docs/product/code-organization.md §7). The safety-critical "which files are +// deleteOrphanFile below (SHFileOperationW on Windows, std::filesystem::remove +// on SWELL platforms) is the ONLY code in the system that deletes USER files — +// the sole deletion authority over the bank folder's bytes (a shell removing a +// transient scratch file it just created, e.g. the drop path's temp +// .vstpreset, is self-cleanup, not authority over user data). Deliberately +// file-local (anonymous namespace): nothing outside this TU can reach it, and +// this concentration must never spread. The safety-critical "which files are // orphans" decision stays in the pure core (prune_reconcile); this TU only -// enumerates, resolves, stats, and — after the R3 confirm — executes. +// enumerates, resolves, stats, and — after the confirm — executes. // -// Compiled into the reaper_reasampler MODULE. REAPER-facing only through the -// persist_detail helpers (active-project read) and usage_scan (the pS-usage -// instance-hold reads); this TU itself calls no REAPER API directly. +// Compiled into the reaper_reasampler module. REAPER-facing only through the +// persist_detail helpers and usage_scan; this TU itself calls no REAPER API directly. #include #include @@ -25,12 +21,11 @@ #include #include -// Move-to-trash surface (fork R-C, trash-preferred). On Windows the Recycle Bin is -// reached via SHFileOperationW + FOF_ALLOWUNDO (verified against the Windows SDK -// shellapi.h: SHFILEOPSTRUCTW { hwnd, wFunc, pFrom(double-NUL list), pTo, fFlags, ... }, -// FO_DELETE=0x3, FOF_ALLOWUNDO=0x40). No portable move-to-trash exists on the SWELL -// (macOS/Linux) side of this codebase, so those platforms fall back to unlink behind the -// R3 dry-run/confirm guardrail — see deleteOrphanFile below for the per-platform routing. +// Move-to-trash surface, trash-preferred. Windows reaches the Recycle Bin via +// SHFileOperationW + FOF_ALLOWUNDO (verified against shellapi.h: SHFILEOPSTRUCTW +// { hwnd, wFunc, pFrom(double-NUL list), pTo, fFlags, ... }, FO_DELETE=0x3, +// FOF_ALLOWUNDO=0x40). No portable move-to-trash exists on SWELL (macOS/Linux), +// so those platforms fall back to unlink — see deleteOrphanFile below. #ifdef _WIN32 #include #include @@ -38,7 +33,7 @@ #include "shell/persist/persist_internal.h" #include "shell/persist/session.h" -#include "shell/persist/usage_scan.h" // liveInstanceHeldPaths (pS-usage: instance holds join `referenced`) +#include "shell/persist/usage_scan.h" // liveInstanceHeldPaths — instance holds join `referenced` #include "core/capture/capture_paths.h" // resolveBankFile / bankRelativeForName / kBankSubfolder #include "core/reclaim/prune_reconcile.h" // the pure orphan decision + report tallies @@ -52,31 +47,24 @@ namespace fs = std::filesystem; using persist_detail::projectDirOf; using persist_detail::readActiveProject; -// The dry-run file-list display cap: the orphan COUNT and reclaimed SIZE are always -// exact (tallied over the full orphan set), but the enumerated file list handed to the -// console is clipped to this many entries so a project with thousands of orphans does -// not flood the report. PruneReport::truncated flags the clip. R3's confirm surface can -// choose its own presentation; this is purely the Wave-2 dry-run readout ceiling. +// The dry-run file-list display cap: count and size are always exact +// (tallied over the full orphan set), but the enumerated list handed to the +// console is clipped so a project with thousands of orphans does not flood +// the report. PruneReport::truncated flags the clip. constexpr std::size_t kPruneListDisplayCap = 64; -// A fresh enumerate + pure-core prune compute for the active project. Shared by the -// dry-run report (pruneDryRun), the full-set query (pruneOrphanSet), and the deletion -// (pruneReclaim) so all three agree on ONE resolution + enumeration + set-algebra path -// (no divergence between what is shown and what is deleted). REAPER-facing (resolves the -// active project, enumerates the folder) but writes nothing. +// A fresh enumerate + pure-core prune compute for the active project. Shared +// by the dry-run report, the full-set query, and the deletion so all three +// agree on one resolution + enumeration + set-algebra path — no divergence +// between what is shown and what is deleted. REAPER-facing but writes nothing. // -// * bankDirAbs — the resolved CURRENT bank folder (absolute, forward-slashed). Empty -// when there is no active/saved project, no project dir, or no folder on -// disk yet -> the caller treats an empty dir as "nothing to reclaim". -// * orphans — the FULL orphan set (owned ∩ present) − referenced, in enumeration -// order, untruncated. The pure core decides; this only supplies inputs. -// * sizeByRel — per-orphan-relative on-disk byte size (0 when it could not be stat'd). -// * abortedUnreadableUsage — true iff a present rsusage_* instance-usage record could -// not be read/decoded (pS-usage fail-safe): `orphans` is left EMPTY — -// the prune must halt rather than proceed with degraded protection. -// An empty orphan set is itself the delete-side guarantee (every -// consumer of this scan deletes at most `orphans ∩ ...`), the flag is -// what lets the action TELL the user instead of claiming "no orphans". +// * bankDirAbs — the resolved current bank folder. Empty when there is no +// active/saved project, no project dir, or no folder yet. +// * orphans — the full orphan set, untruncated. The pure core decides. +// * sizeByRel — per-orphan on-disk byte size (0 when it could not be stat'd). +// * abortedUnreadableUsage — true iff a present rsusage_* record could not +// be read/decoded: `orphans` is left EMPTY, the prune must +// halt rather than proceed with degraded protection. struct PruneScan { std::string bankDirAbs; std::vector orphans; @@ -95,10 +83,8 @@ PruneScan scanPruneOrphans(const BankBook& book, void* proj = readActiveProject(rppPath); if (!proj || rppPath.empty()) return scan; // no active/saved project -> empty scan - // Resolve the CURRENT bank folder the same way the index does (M4): project dir of - // the live .rpp + the fixed bank subfolder. Never a stored absolute path, so a - // Save-As relocation is followed automatically. resolveBankFile is the shared M4 - // arithmetic; feeding it the bank subfolder as the "relative path" yields the folder. + // Resolve the current bank folder the same way the index does — never a + // stored absolute path, so a Save-As relocation is followed automatically. const std::string projectDir = projectDirOf(rppPath); const std::string bankDir = capture::resolveBankFile(projectDir, capture::kBankSubfolder); @@ -109,15 +95,11 @@ PruneScan scanPruneOrphans(const BankBook& book, return scan; // no bank folder captured yet -> nothing to reclaim } - // Enumerate the folder into project-relative index-spelled paths, spelled the SAME - // way the capture path spelled them (bankRelativeForName == deriveBankPaths's - // convention) so the pure core's exact-string match lines up with referencedPaths() - // and the manifest. Non-recursive: the bank folder is flat (capture writes files - // directly here); skip any subdirectory. Size is stat'd here and cached by relative - // path so the report's byte tally reuses the same on-disk read. - // Manual iterator form (it.increment(ec)) keeps the loop non-throwing: a mid-iteration - // failure (file removed, permission flip) breaks out with a best-effort partial list - // rather than propagating std::filesystem_error across REAPER's C ABI. + // Enumerate into project-relative paths spelled the SAME way the capture + // path spells them, so the pure core's exact-string match lines up with + // referencedPaths() and the manifest. Non-recursive: the bank folder is + // flat. Manual iterator form (it.increment(ec)) keeps the loop + // non-throwing on a mid-iteration failure. std::vector present; fs::directory_iterator it(bankDir, ec); for (; !ec && it != fs::directory_iterator{}; it.increment(ec)) { @@ -133,24 +115,20 @@ PruneScan scanPruneOrphans(const BankBook& book, scan.sizeByRel[rel] = sz_ec ? 0 : static_cast(sz); } - // The decision lives in the pure core — read-only inputs from the book and manifest. - // referencedPaths() unions across the whole book (pool included); owned().paths() is - // the manifest set. pS-usage: the referenced set additionally unions every LIVE - // ReaSampler 9000 instance's held captures (usage_scan reads the per-instance - // rsusage_* records + the live FX enumeration; sample_usage decides liveness, - // including the protect-all net when zero instances were identified) — a capture - // any live instance holds can NEVER be an orphan, even when its bank entry was - // deleted while the instance kept its ref. liveInstanceHeldPaths is READ-ONLY, - // preserving this scan's no-write contract. This shell only enumerates, resolves, - // and stats. + // The decision lives in the pure core — read-only inputs from the book and + // manifest. referencedPaths() unions across the whole book; the referenced + // set additionally unions every LIVE ReaSampler 9000 instance's held + // captures (usage_scan + sample_usage decide liveness) — a capture any + // live instance holds can never be an orphan, even if its bank entry was + // deleted while the instance kept its ref. liveInstanceHeldPaths is + // read-only; this shell only enumerates, resolves, and stats. scan.bankDirAbs = bankDir; const UsageScanResult usage = liveInstanceHeldPaths(proj); if (usage.abortPrune) { - // FAIL-SAFE ABORT: a present rsusage_* record could not be read/decoded, so the - // protected set is unknowable. Compute NO orphans — every downstream consumer - // (dry-run report, confirm set, fresh-recompute delete plan) then deletes - // nothing. The flag + key names surface the reason so the action can name each - // offending key for operator recovery. + // FAIL-SAFE ABORT: a present rsusage_* record could not be read/decoded, + // so the protected set is unknowable. Compute NO orphans — every + // downstream consumer then deletes nothing. The key names let the + // action tell the user which keys to recover. scan.abortedUnreadableUsage = true; scan.offendingUsageKeys = usage.offendingKeys; return scan; @@ -162,38 +140,31 @@ PruneScan scanPruneOrphans(const BankBook& book, return scan; } -// Deletes ONE orphan file, trash-preferred (fork R-C, settled). Returns true iff the -// file was deleted BY THIS CALL (reclaimed here). Returns false for two distinct cases: -// * `outAlreadyAbsent` set true — the file was already gone before we touched it; -// the caller folds this into the stale/staleness tally, NOT reclaimedCount. -// * `outAlreadyAbsent` left false — a real delete failure (locked, conversion error); -// the caller folds this into skippedCount. -// `absPath` is the resolved absolute path (forward-slashed). NON-THROWING: no exception -// may cross the C ABI. +// Deletes ONE orphan file, trash-preferred. Returns true iff deleted by this +// call. Returns false with `outAlreadyAbsent` set when the file was already +// gone (caller folds into staleness, not reclaimedCount); false with it unset +// on a real delete failure (locked, conversion error — folds into +// skippedCount). `absPath` is the resolved absolute path. Non-throwing: no +// exception may cross the C ABI. // -// Per-platform routing: -// * Windows — SHFileOperationW(FO_DELETE, pFrom=, FOF_ALLOWUNDO | -// FOF_NOCONFIRMATION | FOF_SILENT | FOF_NOERRORUI). FOF_ALLOWUNDO routes to the -// Recycle Bin (recoverable); the no-UI flags suppress REAPER-blocking dialogs (our -// own confirm already happened). Verified against shellapi.h. `outUsedTrash` set true. -// * Other (SWELL: macOS/Linux) — no portable move-to-trash surface is available in this -// codebase, so fall back to std::filesystem::remove (hard unlink) behind the R3 -// confirm guardrail. `outUsedTrash` left as-is (false). +// Windows routes through SHFileOperationW + FOF_ALLOWUNDO (Recycle Bin, +// recoverable); the no-UI flags suppress REAPER-blocking dialogs since our own +// confirm already happened. Other platforms (SWELL: macOS/Linux) have no +// portable move-to-trash surface, so they fall back to std::filesystem::remove +// (hard unlink) behind the confirm guardrail. bool deleteOrphanFile(const std::string& absPath, bool& outUsedTrash, bool& outAlreadyAbsent) { #ifdef _WIN32 - // Convert forward-slashed UTF-8 to a back-slashed, double-NUL-terminated wide string. - // SHFileOperation's pFrom is a list; a single path still needs the extra terminating - // NUL. Backslashes are required (shell APIs reject forward slashes in some cases). + // Back-slashed, double-NUL-terminated wide string: SHFileOperation's + // pFrom is a list (needs the extra terminating NUL) and rejects forward + // slashes in some cases. std::string win = absPath; for (char& c : win) if (c == '/') c = '\\'; const int wlen = MultiByteToWideChar(CP_UTF8, 0, win.c_str(), -1, nullptr, 0); - if (wlen <= 0) return false; // conversion failed -> real skip (outAlreadyAbsent stays false) + if (wlen <= 0) return false; // conversion failed -> real skip std::vector wbuf(static_cast(wlen) + 1, L'\0'); // +1 for list NUL MultiByteToWideChar(CP_UTF8, 0, win.c_str(), -1, wbuf.data(), wlen); - // wbuf now holds the path + its NUL at [wlen-1]; the extra trailing L'\0' at [wlen] - // makes it the double-NUL-terminated single-element list SHFileOperation wants. SHFILEOPSTRUCTW op{}; op.hwnd = nullptr; @@ -207,22 +178,20 @@ bool deleteOrphanFile(const std::string& absPath, bool& outUsedTrash, outUsedTrash = true; return true; // deleted this call -> reclaimed } - // SHFileOperation failed (e.g. file already gone yields a nonzero code on some - // versions, or a lock). Distinguish "already absent" from a real failure so the - // caller can tally them separately (absent -> staleness skip; failure -> locked skip). + // Distinguish "already absent" (nonzero return on some REAPER versions + // for a vanished file) from a real failure so the caller can tally separately. std::error_code ec; if (!fs::exists(absPath, ec)) { - outAlreadyAbsent = true; // vanished between scan and delete -> staleness, not reclaim + outAlreadyAbsent = true; } return false; #else - // No portable trash surface on SWELL platforms -> hard unlink behind the confirm. + // No portable trash surface on SWELL platforms -> hard unlink. std::error_code ec; const bool removed = fs::remove(absPath, ec); - if (removed) return true; // deleted this call -> reclaimed - if (ec) return false; // a real failure (locked / permission) -> skip - // remove returned false with no error == the file did not exist -> already gone. - outAlreadyAbsent = true; // vanished between scan and delete -> staleness, not reclaim + if (removed) return true; + if (ec) return false; // real failure (locked/permission) -> skip + outAlreadyAbsent = true; // no error, no removal -> already gone return false; #endif } @@ -231,53 +200,47 @@ bool deleteOrphanFile(const std::string& absPath, bool& outUsedTrash, reclaim::PruneReport ReaSamplerSession::pruneDryRun() const { const PruneScan scan = scanPruneOrphans(book_, owned_); - // buildPruneReport tallies count / byte-sum / display-truncation — no report logic - // re-implemented here. An empty scan (no project / no folder) yields a zero report. reclaim::PruneReport report = reclaim::buildPruneReport(scan.orphans, scan.sizeByRel, kPruneListDisplayCap); - // pS-usage fail-safe: surface the unreadable-record abort so the action halts with - // an explicit message instead of reporting "no orphaned files" (the count IS zero — - // the scan computed nothing — but the user must know the prune refused to run). - // The offending key names propagate so the action can name each one for recovery. + // Surface the unreadable-usage abort so the action halts with an explicit + // message instead of reporting "no orphaned files" — the count IS zero, + // but the user must know the prune refused to run. report.abortedUnreadableUsage = scan.abortedUnreadableUsage; report.offendingUsageKeys = scan.offendingUsageKeys; return report; } std::vector ReaSamplerSession::pruneOrphanSet() const { - return scanPruneOrphans(book_, owned_).orphans; // FULL set, untruncated + return scanPruneOrphans(book_, owned_).orphans; // full set, untruncated } reclaim::PruneDeletionResult ReaSamplerSession::pruneReclaim( const std::vector& confirmed) const { reclaim::PruneDeletionResult result; - // Re-enumerate + run the pure core FRESH (never a stale set): the deletion targets - // exactly `confirmed ∩ freshOrphans` (pruneDeletePlan). A file that vanished or became - // referenced between confirm and delete drops out of freshOrphans and is skipped; a - // newly-appeared orphan not in `confirmed` is never swept without its own confirm. - // Because freshOrphans is itself a pure-core output, the plan can contain NO referenced - // and NO hand-dropped file — the R-C/R-D safety survives the recompute. - // pS-usage: if THIS fresh scan hits an unreadable rsusage_* record it aborts with an - // EMPTY orphan set, so the plan below intersects to empty and nothing is deleted — - // the fail-safe holds even in the confirm→delete window, with no extra branch here. + // Re-enumerate + run the pure core FRESH (never a stale set): deletion + // targets exactly `confirmed ∩ freshOrphans`, so a file that vanished or + // became referenced between confirm and delete is skipped, and a newly- + // appeared orphan not in `confirmed` is never swept. If this fresh scan + // hits an unreadable usage record it aborts with an EMPTY orphan set, so + // the plan below intersects to empty and nothing is deleted — the + // fail-safe holds even in the confirm-to-delete window. const PruneScan scan = scanPruneOrphans(book_, owned_); if (scan.bankDirAbs.empty()) return result; // no project / no folder -> nothing const std::vector plan = reclaim::pruneDeletePlan(confirmed, scan.orphans); - // Staleness skip count: entries the user confirmed that are no longer fresh orphans - // (vanished or became referenced between confirm and delete). pruneDeletePlan already - // de-dups confirmed internally, so compute the unique-confirmed size to avoid counting - // de-duplicated entries as stale — that would be dishonest. + // Staleness skip count: confirmed entries no longer fresh orphans. + // pruneDeletePlan de-dups confirmed internally, so compare against the + // unique-confirmed size to avoid counting de-duped entries as stale. const std::size_t uniqueConfirmedCount = std::unordered_set(confirmed.begin(), confirmed.end()).size(); result.skippedCount += uniqueConfirmedCount - plan.size(); for (const std::string& rel : plan) { - // Reconstruct the absolute path from the resolved bank dir + the entry's file name. - // rel is index-spelled "/"; the name is the tail after '/'. + // rel is index-spelled "/"; reconstruct the + // absolute path from the resolved bank dir + the tail after '/'. const std::string::size_type slash = rel.find_last_of('/'); const std::string name = (slash == std::string::npos) ? rel : rel.substr(slash + 1); if (name.empty()) { ++result.skippedCount; continue; } @@ -291,9 +254,7 @@ reclaim::PruneDeletionResult ReaSamplerSession::pruneReclaim( ++result.reclaimedCount; result.reclaimedBytes += bytes; } else if (alreadyAbsent) { - // File vanished between plan and delete — treat as staleness, same as the - // confirm→plan gap above. Does NOT count as reclaimed (we didn't delete it). - ++result.skippedCount; + ++result.skippedCount; // vanished between plan and delete -> staleness } else { ++result.skippedCount; // locked / conversion failure -> recorded, not thrown } diff --git a/src/shell/persist/session.cpp b/src/shell/persist/session.cpp index f84b664..0d9bca7 100644 --- a/src/shell/persist/session.cpp +++ b/src/shell/persist/session.cpp @@ -1,65 +1,25 @@ -// session.cpp — the ReaSamplerSession lifecycle half of the persist seam (Q-W5 -// split of the former persist.cpp; see session.h for the TU map): the poll-driven -// identity-transition detection and the deferred undo/redo reload drain. +// session.cpp — the ReaSamplerSession lifecycle half of the persist seam (see +// session.h for the identity-transition design and the TU map). // -// Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.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; here they are extern (CLAUDE.md §contract). // -// PROJECT-LOAD / SAVE-AS DETECTION (chosen mechanism): -// Driven by REAPER's "timer" register (main.cpp). Each poll() reads the active -// project (EnumProjects(-1)), its .rpp path, and the GUID we store in its ext -// state. Identity is layered GUID-PRIMARY, with the ReaProject* pointer as the -// secondary disambiguator (classifyProjectTransition owns the exact order): -// * different stored GUID -> a different project of record -> LOAD its index; -// NEVER relocate. Catches pointer RECYCLING (REAPER reuses a closed project's -// address, so a reopened/new project can present the previous pointer with a -// different GUID), new/unsaved<->saved, and switching between distinct saved -// projects. -// * SAME GUID, DIFFERENT object -> a forked sibling that copied our GUID via -// Save-As -> LOAD its index; NEVER relocate; re-GUID it so the siblings -// diverge going forward. -// * SAME GUID, SAME object, .rpp path changed -> genuine Save-As to a new -// location -> relocate the bank folder from the old dir to the new one, then -// re-GUID. -// Why GUID-primary (W12 fix): this layers the two prior designs. M4 (GUID-only) -// broke Save-As forks — Save-As copies the whole .rpp incl. our stored GUID, so a -// fork and its parent share a GUID on disk; switching between them read as a -// Save-As and clobbered a bank. W10 (pointer-primary, GUID voided) broke pointer -// RECYCLING — a reopened/new project reusing the previous project's address read -// as NoOp/SaveAsRelocate and the bank never reloaded. Checking the GUID first -// catches recycling; the pointer then separates a fork (same GUID, different -// object -> Load) from a Save-As (same GUID, same object, new path -> relocate). -// classifyProjectTransition (pure, capture_paths) takes a `sameProjectObject` -// bool (poll() computes `proj == lastProject_`) so the decision stays REAPER-free -// and testable; poll() executes the verdict. +// classifyProjectTransition (pure, capture_paths) takes a `sameProjectObject` +// bool so the decision stays REAPER-free and testable; poll() executes the +// verdict. REAPER exposes no stable per-project GUID, so we mint one (genGuid/ +// guidToString) under kProjExtGuidKey; Save-As copies the whole .rpp including +// our ext state, so the new project initially shares the old GUID, and poll() +// re-GUIDs it after relocating (or on the forked-sibling Load branch). // -// REAPER exposes no stable per-project GUID (GetSetProjectInfo_String has no -// PROJECT_GUID desc; GetProjectStateChangeCount is a session-local counter, not -// a cross-open identity), so we MINT one with genGuid/guidToString and store it -// under kProjExtGuidKey (ext_state_io.cpp owns the minting helpers). On Save-As -// REAPER copies the whole .rpp incl. our ext state, so the new project initially -// shares the old GUID; poll() re-GUIDs it (after relocating, or on the forked- -// sibling Load branch) so identities diverge. -// -// Rationale for the timer: the brief mandates ext-state storage (rules out the -// projectconfig .rpp-line hook for STORAGE), and the timer composes cleanly with -// ext-state while covering identity-transition load + Save-As detection in one -// place. -// -// DIVISION OF LABOUR (R-B undo): -// * Identity-transition poll (this file, classifyProjectTransition) owns -// open / tab-switch / new / forked-sibling / Save-As-relocation — every case -// where the project OF RECORD changes. -// * The `projectconfig` hook (main.cpp registers project_config_extension_t; -// BeginLoadProjectState with isUndo) owns UNDO/REDO — where the project -// identity is unchanged but its ext state rolled back/forward on disk. The -// identity poll sees NoOp there and would never re-read ext state, so the hook -// requests a reload (requestReload) that poll() drains on the next tick, once -// REAPER has restored the block. See requestReload / the poll drain. -// The hook fires on undo AND redo (isUndo true for both), and on normal open -// (isUndo false) — but we set the reload flag ONLY for isUndo, so a normal open -// flows solely through the identity-transition Load path and never double-loads. +// Division of labour for undo/redo: the identity-transition poll (this file) +// owns open/tab-switch/new/forked-sibling/Save-As. The `projectconfig` hook +// (main.cpp, BeginLoadProjectState with isUndo) owns undo/redo, where identity +// is unchanged but ext state rolled back/forward on disk — the identity poll +// would see NoOp there, so the hook requests a reload that poll() drains next +// tick, once REAPER has restored the block. The hook fires on +// undo, redo, AND normal open, but the reload flag is set only for isUndo, so +// a normal open never double-loads. #include "shell/persist/session.h" @@ -91,9 +51,8 @@ bool ReaSamplerSession::consumeLoadSignal() { } void ReaSamplerSession::requestReload() { - // Set-only; poll() drains it on the next tick (see the poll() drain block for why - // the read is deferred past the projectconfig callback). Cheap and idempotent — - // multiple undo/redo callbacks before the next tick collapse to one reload. + // Set-only; poll() drains it next tick. Idempotent — multiple undo/redo + // callbacks before the next tick collapse to one reload. reloadRequested_ = true; } @@ -116,19 +75,13 @@ void ReaSamplerSession::poll() { return; } - // Undo/redo reload (owner: the projectconfig hook, NOT the identity classifier - // below). An undo/redo keeps the SAME project identity — same ReaProject*, GUID, - // and .rpp path — so classifyProjectTransition would return NoOp and never re-read - // ext state, leaving book_/view_ stale after the on-disk ext state rolled back. - // The projectconfig BeginLoadProjectState callback (isUndo) raised reloadRequested_ - // one or more ticks ago; by NOW REAPER has finished restoring the project's - // block, so GetProjExtState returns the POST-undo value. Reload from the - // current active project and identity-adopt it (no relocation — the path is - // unchanged), then return. loadFromProject raises loadPending_, so the existing - // consumeLoadSignal() glue re-baselines the panel detector and reapplies the active - // mode; bankPanelRefresh's fingerprint pass then repaints the restored book. This is - // the ONLY undo/redo reload path — the timer never polls ext-state CONTENT to detect - // an undo (Daniel's directive: the hook drives it, not a poll heuristic). + // Undo/redo reload, owned by the projectconfig hook, not the identity + // classifier below: an undo/redo keeps the same project identity, so + // classifyProjectTransition would return NoOp and never re-read ext + // state. By now REAPER has finished restoring the block, so + // GetProjExtState returns the post-undo value. Reload and identity-adopt + // (no relocation — path unchanged). This is the ONLY undo/redo reload + // path — the timer never polls ext-state content to detect an undo. if (reloadRequested_) { reloadRequested_ = false; loadFromProject(proj, projectDirOf(rppPath)); @@ -150,20 +103,14 @@ void ReaSamplerSession::poll() { return; case capture::ProjectTransition::Load: { - // A different project of record is active (open / tab switch / new / - // reopened / recycled pointer / forked sibling). Load ITS index; never - // relocate. + // A different project of record is active. Load its index; never relocate. // - // Forked-sibling divergence: gate on `!sameProjectObject` so this fires - // ONLY for a step-2 Load (same GUID, different object) — a Save-As fork - // that copied our GUID and never re-saved (its fresh GUID was runtime- - // only on the sibling we came from). A recycled-pointer Load (step 1: - // currentGuid != lastGuid_) must NOT re-GUID — it is already a distinct - // identity. currentGuid == lastGuid_ can only hold here when step 1 did - // NOT fire, i.e. this is the fork case; the explicit !sameProjectObject - // makes that intent load-bearing rather than incidental. Do this BEFORE - // loadFromProject reads the index (order is irrelevant — GUID and - // bank_index are distinct keys — but self-contained is clearest). + // Forked-sibling re-GUID: gate on `!sameProjectObject` so this fires + // only for the fork case (same GUID, different object) — a Save-As + // fork that copied our GUID and never re-saved. A recycled-pointer + // Load (currentGuid != lastGuid_) must NOT re-GUID — it is already + // a distinct identity; currentGuid == lastGuid_ can only hold here + // when that case did not fire. if (proj && !sameProjectObject && !currentGuid.empty() && currentGuid == lastGuid_ && !rppPath.empty()) { const std::string fresh = genProjectGuidString(); @@ -186,12 +133,10 @@ void ReaSamplerSession::poll() { } case capture::ProjectTransition::SaveAsRelocate: { - // SAME project object + new .rpp path: a genuine Save-As (the pointer - // proves it — a fork tab-switch is a DIFFERENT object and took the Load - // branch above). Relocate the bank folder from the old dir to the new - // one so the wavs sit under the new .rpp and the index's relative paths - // still resolve. Keep the in-memory bank as-is (Save-As copied our ext - // state, the relative paths are unchanged) — do NOT reload. + // Same project object, new .rpp path: a genuine Save-As. Relocate + // the bank folder so the wavs sit under the new .rpp and the + // index's relative paths still resolve. Keep the in-memory bank + // as-is (Save-As copied our ext state) — do NOT reload. const std::string oldDir = projectDirOf(lastRppPath_); const std::string newDir = projectDirOf(rppPath); const capture::BankRelocation plan = @@ -200,11 +145,9 @@ void ReaSamplerSession::poll() { relocateBankFolder(plan.oldBankDir, plan.newBankDir); } - // Save-As duplicated our ext state, so the new project B currently - // shares A's GUID. Mint a FRESH GUID for B and write it, so A and B - // no longer collide on identity when reopened later. Adopt the fresh - // GUID as our last-seen identity. Mark dirty so the fresh GUID flushes - // to the new .rpp on the next normal save / close-prompt. + // Save-As duplicated our ext state, so the new project shares the + // old GUID; mint a fresh one and mark dirty so it flushes on the + // next save, and A/B no longer collide on identity when reopened. const std::string fresh = genProjectGuidString(); if (proj) { SetProjExtState(static_cast(proj), projExtNamespace(), diff --git a/src/shell/persist/session.h b/src/shell/persist/session.h index 0bec588..6267160 100644 --- a/src/shell/persist/session.h +++ b/src/shell/persist/session.h @@ -1,36 +1,23 @@ #pragma once -// session — the ReaSamplerSession lifecycle owner of the persist seam (Q-W5 split of -// the former persist god-TU; CLAUDE.md §load-bearing split; CONTEXT.md §Persistence & -// paths). One class, three implementation TUs by responsibility: +// session — the ReaSamplerSession lifecycle owner of the persist seam. One +// class, three implementation TUs by responsibility: +// * session.cpp — poll() identity-transition detection (load / Save-As / +// forked sibling / recycled pointer) + the deferred undo/redo reload drain. +// * ext_state_io.cpp — save/load/writeAssignmentRequest: the ext-state <-> +// JSON bridge, GUID minting, bank-folder relocation (see ext_state_io.h). +// * prune_fs.cpp — pruneDryRun/pruneOrphanSet/pruneReclaim: the prune +// scan and THE SINGLE FILE-DELETION AUTHORITY over user files in the bank +// folder. Nothing else in the system deletes bank-folder bytes (a shell's +// self-cleanup of its own transient scratch file is not this authority). // -// * session.cpp — poll() (identity-transition detection: load / Save-As / -// forked sibling / recycled pointer) + the deferred undo/redo reload drain -// (requestReload, raised by main.cpp's projectconfig BeginLoadProjectState hook) -// + the D4 load signal. -// * ext_state_io.cpp — saveToActiveProject / loadFromProject / -// writeAssignmentRequest: the ext-state ↔ JSON serialization bridge, plus GUID -// minting and bank-folder relocation (see ext_state_io.h for the key contract). -// * prune_fs.cpp — pruneDryRun / pruneOrphanSet / pruneReclaim: the prune scan -// and THE SINGLE FILE-DELETION AUTHORITY over USER files in the bank folder in -// ReaSampler (deleteOrphanFile via SHFileOperationW). Nothing else in the system -// deletes bank-folder bytes; a shell's self-cleanup of a transient scratch file -// it just created (the drop path's .vstpreset temp, the realtime finalize temp) -// is excluded from this authority. +// Save: BankModel JSON -> SetProjExtState under namespace "reasampler" (ext +// state lives inside the .rpp, so the index travels with the project for +// free). Load: GetProjExtState -> deserialize -> resolve each entry's bank +// file against the CURRENT project dir, so a project opened from a new +// location still finds its bank. Save-As: relocate the physical bank folder +// so the wavs end up under the new .rpp; the index's relative paths stay valid. // -// Save: serialize the BankModel JSON -> SetProjExtState under namespace -// "reasampler" (ext state lives inside the .rpp, so the index travels with the -// project for free). -// Load: on project load, GetProjExtState -> bank_model::deserialize -> in-memory -// BankModel, then resolve each entry's bank file against the CURRENT project -// dir (project-relative resolution — a project opened from a new location still -// finds its bank). -// Save-As: when the project path changes, relocate the physical bank folder so -// the wavs end up under the new .rpp (the index's relative paths stay valid). -// -// The header is REAPER-free (no SDK types leak here): callers interact through a -// ReaSamplerSession that owns the bank and the persist lifecycle. All REAPER API -// calls live in the three TUs. It depends on bank_model (pure) for JSON round-trip -// and capture_paths (pure) for the path arithmetic it drives. +// REAPER-free header — all REAPER API calls live in the three TUs. #include #include @@ -46,268 +33,133 @@ namespace reasampler { -// Owns the session's BankBook (Phase B: pool + named banks) and drives persistence +// Owns the session's BankBook (pool + named banks) and drives persistence // against the active REAPER project. One instance lives for the extension's -// lifetime (main.cpp). It tracks -// the project identity it last saw so the timer tick can detect a project load -// (a different project became active) and a Save-As (SAME project, path changed): +// lifetime. Tracks the project identity last seen so the timer tick can +// detect a project load (a different project became active, so load the +// index from ext state) vs. a Save-As (same project, path changed, so +// relocate the bank folder under the new .rpp). // -// * project load -> load the index from ext state, resolve bank paths -// * Save-As (new dir) -> relocate the bank folder under the new .rpp +// Identity is layered GUID-primary: the minted GUID (content-based, immune to +// REAPER recycling a closed project's ReaProject* address) is checked first; +// the live pointer disambiguates only the same-GUID case — a forked sibling +// (same GUID, different object -> Load) vs. a genuine Save-As (same GUID, +// same object, new path -> relocate). Two prior designs each broke one +// direction: GUID-only misread a Save-As fork as the parent project; +// pointer-primary misread a recycled ReaProject* address as no-op. GUID-first +// catches recycling; the pointer then separates fork from Save-As. // -// Identity is layered GUID-PRIMARY: the minted GUID (content-based identity of -// record, immune to REAPER recycling a closed project's ReaProject* address) is -// checked FIRST, and the live pointer disambiguates only the same-GUID case — a -// forked sibling (same GUID, different object -> Load) vs a genuine Save-As (same -// GUID, same object, new path -> relocate). GUID-first catches pointer recycling -// (a reopened/new project reusing the previous address with a different GUID — the -// W12 defect that stopped the bank reloading); the pointer catches forks (Save-As -// copies our GUID onto a distinct object — the W10 defect that clobbered a bank). -// -// The book itself is exposed for the capture/action layer to mutate; persist -// only reads it on save and replaces it on load. +// The book is exposed for the capture/action layer to mutate; persist only +// reads it on save and replaces it on load. class ReaSamplerSession { public: ReaSamplerSession() = default; - // The multi-bank book (Phase B): the pool + named banks, each wrapping a - // BankModel, plus the active-bank id. The action layer (B3) creates / renames / - // reorders / deletes banks and moves samples here; the panel (B4) reads it; - // persist serializes it under the `banks` key on save and replaces it on load. + // Pool + named banks + active-bank id; persist serializes under `banks`. BankBook& book() { return book_; } const BankBook& book() const { return book_; } - // The capture add-target: the ACTIVE bank's BankModel (defaults to the pool). - // The capture path adds a captured Sample through this seam, so a capture lands - // in whichever bank is active — the single behavioural change B2 wires in over - // M7/M8 (the capture backends are untouched; only the target index moved). The - // panel/insert readers that displayed the single index continue to read it here - // unchanged; today it resolves to the pool (default active), matching prior - // single-bank behaviour, until B3/B4 let the user switch the active bank. + // The capture add-target: the active bank's BankModel (defaults to the pool). model::BankModel& bank() { return book_.activeIndex(); } const model::BankModel& bank() const { return book_.activeIndex(); } - // The in-memory Design-View model. The view/action layer mutates it (tag, - // toggle, snapshot); persist serializes it on save and replaces it on project - // load — exactly as it treats the bank. D3 persists MODEL STATE only; applying - // visibility/processing (reapply-on-open) is D4's job, not this member's. + // Design-View model; persists MODEL STATE only (visibility on open is the view shell's job). ViewModeModel& view() { return view_; } const ViewModeModel& view() const { return view_; } - // The docked panel's tail setting (mode + manualMs), authoritative here — NOT in - // panel state — so it travels inside the .rpp: persist serializes it on save and - // replaces it on project load exactly as it treats the bank and view model. The - // panel reads/writes it through this seam (bank_panel holds the session), and the - // capture actions read it via bankPanelTailSetting. Default None / 2 s manual for - // an unsaved or pre-feature project (no stored key -> this default survives load). + // Docked panel's tail setting, authoritative here so it travels inside the .rpp. capture::TailSetting& tail() { return tail_; } const capture::TailSetting& tail() const { return tail_; } - // The owned-file manifest (Phase B B-cap): the set of project-relative files the - // capture path itself created. The capture add-path records each created file here - // (main.cpp, alongside the bank add), exactly as it adds the Sample to the active - // bank; persist serializes it under the `owned_files` key on save and replaces it on - // project load / undo-reload — peer to book_/view_/tail_. Phase R prune CONSUMES it; - // B-cap only writes and persists it (no prune logic here). + // Project-relative files the capture path itself created; prune consumes it. model::OwnedFileManifest& owned() { return owned_; } const model::OwnedFileManifest& owned() const { return owned_; } - // The ReaSampler version that last WROTE the active project, recovered from its - // ext-state stamp on load (Phase V, V1). PreVersioning when the project carries no - // stamp (saved before this feature), Unknown for a malformed stamp, Stamped with the - // exact stored string otherwise — all silent, never an error. Replaced on every load - // path (peer-symmetry with bank_/view_/tail_); default PreVersioning for an unsaved - // or never-loaded session. Exposed so a future migration step (or diagnostics) can - // reason about the origin build without re-reading ext state. + // The version that last wrote the active project: PreVersioning (no + // stamp), Unknown (malformed), or Stamped. const version::WritingVersion& writingVersion() const { return writingVersion_; } - // The S9 bank-generation counter (the value stamped under `bank_generation`). Monotonic - // per project: recovered on load (so it continues from the stored value rather than - // resetting), bumped by bank-content mutations via bumpBankGeneration(), and written on - // every saveToActiveProject(). Exposed const for the writer sites to read/log. + // Monotonic per project; recovered on load, written on every saveToActiveProject(). std::int64_t bankGeneration() const { return bankGeneration_; } - // Bump the S9 bank-generation counter — call at every bank-CONTENT mutation that changes - // what a live instance would PLAY (capture add, re-capture-in-place, sample remove, - // move/copy affecting banks, ingest import). NOT the pure-organizational verbs (create / - // rename / activate / reorder a bank), which change no existing (bankId, sampleId) -> - // content mapping. The bumped value is persisted by the NEXT saveToActiveProject() call - // the same mutation already makes (the counter rides the persist blob, so there is no - // separate write). In-memory only here — cheap and REAPER-free; the persist is the write. - // Over-bumping is safe (a reload that finds unchanged content atomically re-installs the - // same instrument, no glitch); under-bumping misses a hands-free refresh, so the sites err - // toward bumping. Idempotent per logical op — call once per mutation, before the persist. + // Call at every bank-CONTENT mutation that changes what a live instance + // would play, NOT the organizational verbs (create/rename/reorder a + // bank). Rides the next persist. Over-bumping is safe; under-bumping + // misses a hands-free refresh, so call sites err toward bumping. void bumpBankGeneration() { ++bankGeneration_; } - // 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). - // - // Returns true iff a persist actually happened (an active, SAVED project existed); - // false when it no-op'd (no active project, or an unsaved one with no .rpp). Lets a - // caller wrapping this in an undo block skip the block when nothing was written, so - // no dangling no-effect undo entry is opened on an unsaved project. + // Serializes book/view/tail to ext state, clears the retired legacy + // `bank_index` key. No-ops with no active/saved project. Returns true iff + // a persist happened, so a caller can skip an undo block when nothing was written. bool saveToActiveProject(); - // Compute the Phase R prune dry-run for the ACTIVE project (Wave 2 — REPORT ONLY, - // deletes nothing). Enumerates the resolved CURRENT bank folder (the SAME M4 project- - // relative machinery the index/persist use — never a stale absolute path, so it is - // correct across a Save-As relocation), spells every enumerated entry with the index's - // own convention (bankRelativeForName — byte-identical to the capture path's spelling), - // and feeds the R1 pure core with (present, referenced, owned().paths()) where - // `referenced` = book().referencedPaths() ∪ every LIVE ReaSampler 9000 instance's - // held captures (pS-usage: usage_scan reads the per-instance rsusage_* ext-state - // records + the live FX enumeration; sample_usage decides liveness) — a capture any - // live instance holds can never be an orphan, so the prune can never delete it. - // FAIL-SAFE: a present-but-unreadable usage record sets the report's - // abortedUnreadableUsage flag with an EMPTY orphan set — the prune action halts. - // Returns the orphan count + reclaimable bytes + the (possibly display-truncated) file - // list. The decision stays in the pure core — this method only enumerates, resolves, - // and stats. READ-ONLY across the whole persist seam: it writes NO ext-state, calls no - // save / MarkProjectDirty, and mutates neither the book, the manifest, nor any file. - // - // Yields an empty report (count 0) when there is no active/saved project or no bank - // folder on disk yet — an unsaved or never-captured project has nothing to reclaim. + // Report-only prune dry-run: feeds the pure core with (present, + // referenced, owned), where `referenced` = book references union every + // live instance's held captures (usage_scan + sample_usage decide + // liveness). FAIL-SAFE: an unreadable usage record sets + // abortedUnreadableUsage with an EMPTY orphan set. Read-only throughout. reclaim::PruneReport pruneDryRun() const; - // The FULL (untruncated) prune orphan set for the ACTIVE project — the same fresh - // enumerate + pure-core compute pruneDryRun() runs, but returning EVERY orphan (no - // 64-cap display clip) as project-relative index-spelled paths, in enumeration order. - // The R3 action calls this to obtain the exact set it will CONFIRM and then delete - // (pruneDryRun's truncated list is for the console readout; the delete set must be - // complete). READ-ONLY — no ext-state, no save, no file mutation. Empty when there is - // no active/saved project or no bank folder yet. + // The full (untruncated) orphan set, same compute as pruneDryRun. The + // prune action confirms this set before deleting it. Read-only. std::vector pruneOrphanSet() const; - // Phase R (Reclaim), R3: DELETE the confirmed orphan set — the SOLE file-deletion path - // in ReaSampler, callable ONLY after an explicit user confirm of a specific manifest. - // Given the orphan set the user was shown and confirmed (`confirmed`, typically the - // full pruneOrphanSet() captured moments earlier), this re-enumerates the folder, runs - // the pure core FRESH, and deletes exactly `confirmed ∩ freshOrphans` (pruneDeletePlan) - // so a file that vanished or became referenced between confirm and delete is skipped, - // never wrongly deleted — and a newly-appeared orphan the user did NOT see is never - // swept. Deletion routes to the OS trash where a portable move-to-trash is verified - // (Windows Recycle Bin via SHFileOperation + FOF_ALLOWUNDO); elsewhere it falls back to - // std::filesystem unlink behind this confirm guardrail (see prune_fs.cpp for - // per-platform routing). Non-throwing: every filesystem call uses error_code forms; a - // per-file failure (locked, already gone) is recorded and skipped, never thrown across - // the C ABI. - // - // Does NOT modify the BankModel/book (orphans are unreferenced by definition) and does - // NOT modify the OwnedFileManifest (a reclaimed file drops out of the (owned ∩ present) - // algebra naturally once it is off disk — no persist write, so no undo-point question - // and no risk to the referenced/owned safety). Writes NO ext-state at all. - // - // No-ops (empty result) when there is no active/saved project, no bank folder, or the - // delete plan is empty (everything went stale). The caller is responsible for having - // shown the confirm; this method does NOT prompt. + // Delete the confirmed orphan set — the sole file-deletion path, + // callable only after an explicit user confirm. Re-enumerates and runs + // the pure core fresh, deleting exactly `confirmed ∩ freshOrphans` so a + // file that vanished or became referenced since confirm is skipped, and + // an orphan the user did not see is never swept. Trash-preferred + // (Windows Recycle Bin; unlink elsewhere). Does not modify the book or + // OwnedFileManifest, writes no ext-state. No-ops when nothing to delete; + // does not prompt. reclaim::PruneDeletionResult pruneReclaim( const std::vector& confirmed) const; - // Write the S8 ingest ASSIGNMENT REQUEST to the active project's ext state (the - // `assign_request` key, namespace "reasampler"): the extension telling the active - // sampler instance "play THIS sample now." `wire` is the pure assignment_request - // encoding (assignment_request.h); this method only routes the already-encoded value - // to ext state + MarkProjectDirty — the (bankId, sampleId, generation) shaping and - // the encode live in the ingest shell (the pure module) so persist stays a thin bridge. - // - // A SIBLING one-shot write, NOT part of saveToActiveProject's book/view/tail blob: an - // assignment request is a transient "just assigned" signal the instrument reads and - // acts on, so it rides its own key and is written only at ingest time, never on every - // book save. Returns true iff written (an active, SAVED project existed); false on a - // no-active / unsaved project (nothing to write into — the assign is dropped, matching - // the book/manifest quiet-persist idiom the ingest add-path already tolerates). + // Write the ingest assignment request (`assign_request` key): "the active + // sampler instance should now play THIS sample." `wire` is pre-encoded + // (assignment_request.h); a sibling one-shot write, not part of + // saveToActiveProject's blob. Returns true iff written. bool writeAssignmentRequest(const std::string& wire); - // Poll the active project. Detects a project load (active project changed) - // and a Save-As (active project's .rpp path changed) and reacts accordingly. - // Intended to be driven by REAPER's "timer" register. Idempotent per tick. - // - // Also drains a pending undo/redo reload (requestReload): a Ctrl-Z / Ctrl-Shift-Z - // keeps the SAME project identity (same ReaProject*/GUID/.rpp path), so the - // identity classifier below reads it as NoOp and would never re-read ext state. - // The projectconfig hook (main.cpp) raises the reload flag on an undo/redo state - // restore; poll() honours it FIRST — reloading book_ + view_ + tail_ from the - // (now-restored) ext state of the current project — before the identity check, so - // the undo is reflected in-session without any content polling. + // Detects a project load or Save-As and reacts. Driven by REAPER's + // "timer" register; idempotent per tick. Also drains a pending undo/redo + // reload (requestReload): the identity classifier alone would read an + // undo/redo as NoOp since identity is unchanged, so the projectconfig + // hook's reload flag is honored FIRST, before the identity check. void poll(); - // Request a reload of book_ + view_ + tail_ from the CURRENT active project's ext - // state on the next poll() tick. Raised by the projectconfig hook (main.cpp) ONLY - // on an undo/redo state restore (isUndo). Deferred (a flag, not an immediate read) - // because the projectconfig callback fires BEFORE REAPER has restored the project's - // block — reading GetProjExtState synchronously there would return the - // PRE-undo value. Draining it on the next timer tick reads the restored value. This - // is REAPER-facing shell state; the request itself carries no REAPER types. + // Request a reload of book_/view_/tail_ on the next poll() tick. Raised + // by the projectconfig hook only on an undo/redo state restore. Deferred + // because the hook fires BEFORE REAPER restores the block — + // reading synchronously there would return the pre-undo value. void requestReload(); - // Load signal for the D4 reapply-on-open glue. poll() raises this whenever it - // (re)loads the view model from a project — prime, a project switch/open, or a - // forked-sibling load. consumeLoadSignal() returns true ONCE per load and clears - // it, so the integration layer (main.cpp) can react by reapplying the saved - // active mode's visibility exactly once, then goes quiet on idle ticks. - // - // Signal-based seam by design: persist stays MODEL-ONLY (it never calls the view - // shell), so there is no persist -> view dependency. main.cpp owns the glue — - // it drives both persist.poll() and view::applyMode, so the reapply wiring lives - // where those two already meet. D3 deliberately deferred exactly this to D4. + // Load signal for the reapply-on-open glue: poll() raises this whenever + // it (re)loads the view model; consumeLoadSignal() returns true once and + // clears it. Signal-based since persist stays model-only (never calls + // the view shell); main.cpp owns the glue. bool consumeLoadSignal(); private: BankBook book_; + ViewModeModel view_; // reset to default on a project with no stored view_state + capture::TailSetting tail_; // reset to default (None / 2s) with no stored tail key + model::OwnedFileManifest owned_; // reset to empty/stored on EVERY load path, never inherited + version::WritingVersion writingVersion_; // recovered per load; PreVersioning default + std::int64_t bankGeneration_ = 0; // recovered per load (absent -> 0); monotonic - // The Design-View model. Default-constructed = Arrange + Design seeded, active - // = Arrange; loadFromProject leaves this default when a project has no stored - // 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_. - capture::TailSetting tail_; - - // The owned-file manifest. Default empty; loadFromProject resets it to empty (or the - // stored set) on EVERY load path (peer-symmetry with book_/view_/tail_): switching to - // a project with no stored manifest must not inherit the previous project's ownership - // record, and an undo that rolled back a capture must re-read the restored manifest so - // the in-memory set matches disk. Absent key -> empty is graceful (older project). - model::OwnedFileManifest owned_; - - // The writing-version stamp recovered on load (Phase V). Default PreVersioning; - // loadFromProject replaces it on every load path (peer to bank_/view_/tail_), so - // switching to a pre-versioning project reports PreVersioning rather than inheriting - // the previous project's stamp. Read-only to consumers via writingVersion(). - version::WritingVersion writingVersion_; - - // The S9 bank-generation counter (peer to writingVersion_). Recovered on EVERY load path - // from the stored `bank_generation` stamp (parseBankGeneration; absent -> 0), so it - // continues monotonic from the persisted value across reopen and resets cleanly on a - // project switch (a different project's counter, not the previous one's). bumped by - // bumpBankGeneration() at bank-content mutations and stamped by saveToActiveProject(). - // Default 0 for an unsaved / never-loaded / pre-S9 session. - std::int64_t bankGeneration_ = 0; - - // 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 - // same-GUID case (different object = forked sibling -> Load; same object + new - // path -> Save-As) and drives forked-sibling re-divergence; the path tells a - // Save-As from an idle tick. - // Held as void* so the header stays REAPER-free; it is a compared-only opaque - // handle (never dereferenced), so a stale/recycled address is harmless. - void* lastProject_ = nullptr; // last active ReaProject* (opaque; compare only) + // Project identity last observed by poll(). GUID is primary; the pointer + // disambiguates the same-GUID case. Held as void* (compare-only, never + // dereferenced) so the header stays REAPER-free. + void* lastProject_ = nullptr; std::string lastGuid_; // "" until the first saved project is seen - std::string lastRppPath_; // .rpp path last seen for lastProject_ + std::string lastRppPath_; bool primed_ = false; // false until the first poll() observes state bool loadPending_ = false; // raised by loadFromProject; drained by consumeLoadSignal - bool reloadRequested_ = false; // raised by requestReload (projectconfig undo/redo); drained by poll + bool reloadRequested_ = false; // raised by requestReload; drained by poll - // Load the book from the given project's ext state (the `banks` key, else the - // legacy `bank_index` key migrated into the pool) and resolve bank paths against - // projectDir at read time. Replaces the in-memory book. Also restores view_, tail_, - // and owned_ from their sibling keys on every load path. projectDir empty -> the - // book is reset to empty (unsaved project has no resolvable banks). + // Load the book from `proj`'s ext state (`banks`, else legacy + // `bank_index` migrated into the pool); also restores view_/tail_/owned_. void loadFromProject(void* proj, const std::string& projectDir); }; diff --git a/src/shell/persist/usage_scan.cpp b/src/shell/persist/usage_scan.cpp index 2c8b261..cbed7f7 100644 --- a/src/shell/persist/usage_scan.cpp +++ b/src/shell/persist/usage_scan.cpp @@ -1,20 +1,14 @@ -// usage_scan.cpp — see usage_scan.h. The REAPER reads behind the pS-usage prune -// protection; every decision is in the pure sample_usage module, this TU only reads. +// usage_scan.cpp — see usage_scan.h. The REAPER reads behind the instance-usage +// prune protection; every decision is in the pure sample_usage module, this TU +// only reads. // -// 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). Every REAPER symbol used here is verified against -// vendor/reaper-sdk/sdk/reaper_plugin_functions.h: -// * EnumProjExtState(proj, extname, idx, keyOut, sz, valOut, sz) -> bool (~1272) -// * GetProjExtState(proj, extname, key, valOut, sz) -> int (~2591) -// * CountTracks / GetTrack / GetMasterTrack (track scan) -// * TrackFX_GetCount(MediaTrack*) / TrackFX_GetRecCount(MediaTrack*) (~7283/7570) -// * TrackFX_GetNamedConfigParm(MediaTrack*, int, parm, buf, sz) -> bool (~7377) -// * CountMediaItems / GetMediaItem (~423/1964) -// * CountTakes(MediaItem*) / GetMediaItemTake(MediaItem*, int) (~471/2029) -// * GetMediaItemTrack(MediaItem*) (~2133) -// * TakeFX_GetCount / TakeFX_GetNamedConfigParm (~6710/6774) -// * guidToString (via track_guid::guidString) +// 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). REAPER symbols used here (EnumProjExtState, +// GetProjExtState, CountTracks/GetTrack/GetMasterTrack, TrackFX_GetCount/ +// GetRecCount/GetNamedConfigParm, CountMediaItems/GetMediaItem, CountTakes/ +// GetMediaItemTake, GetMediaItemTrack, TakeFX_GetCount/GetNamedConfigParm) are +// verified against vendor/reaper-sdk/sdk/reaper_plugin_functions.h. #include "shell/persist/usage_scan.h" @@ -26,11 +20,11 @@ #include #include "core/version/app_version.h" // vstPluginName / vstOutputName (channel name needles) -#include "core/wire/ext_state_read.h" // readProjExtStateGrowing (T2-04: the ONE grow-loop policy) +#include "core/wire/ext_state_read.h" // readProjExtStateGrowing — the shared grow-loop policy #include "ext_keys.h" // kProjExtNamespace / kProjExtUsageKeyPrefix #include "core/wire/instrument_drop.h" // vstClassIdHex — the frozen channel class-UID hex #include "core/wire/sample_usage.h" // identityMatches, foldUsageRecords (the pure decisions) -#include "shell/capture/track_guid.h" // guidString — the ONE canonical GUID key formatter +#include "shell/capture/track_guid.h" // guidString — the canonical GUID key formatter #define REAPERAPI_MINIMAL #define REAPERAPI_WANT_EnumProjExtState @@ -52,10 +46,9 @@ namespace reasampler { -// Real-namespace-home using-directive (Q-W6: the namespaces.h shim is retired): -// this TU speaks the sample_usage wire vocabulary wholesale (UsageRecord / -// decodeUsageRecord / foldUsageRecords / identityMatches / toUpperAscii) plus the -// channel-identity accessors + the preset class-id hex. +// This TU speaks the sample_usage wire vocabulary wholesale (UsageRecord / +// decodeUsageRecord / foldUsageRecords / identityMatches / toUpperAscii) plus +// the channel-identity accessors + the preset class-id hex. using namespace reasampler::wire; using version::vstOutputName; using version::vstPluginName; @@ -76,16 +69,15 @@ struct FxIdentityNeedles { using FxParmGetter = std::function; -// True if any FX in the (possibly container-nested) sub-chain rooted at `fxId` is a -// ReaSampler 9000. BOTH fx_ident and original_name are checked on BOTH chain kinds (a -// renamed instance may keep its original_name; fx_ident carries the module path — the -// primary identification net is the module filename base via fx_ident, which holds even -// after a user renames the FX instance). Containers are walked via -// the documented container_count / container_item.X addressing (v7.06+); on a chain -// kind or REAPER version without containers the parm read returns empty and recursion -// is a no-op. `depth` bounds pathological nesting. fx_ident is queried per FX — chain -// enumeration is chunk-level, so OFFLINE instances match too (load-bearing: a -// Design-View-parked instance must keep protecting its holds). +// True if any FX in the (possibly container-nested) sub-chain rooted at +// `fxId` is a ReaSampler 9000. Both fx_ident and original_name are checked (a +// renamed instance may keep its original_name; fx_ident carries the module +// path and survives a rename). Containers are walked via the documented +// container_count / container_item.X addressing (v7.06+); on a chain kind or +// REAPER version without containers the parm read returns empty and +// recursion is a no-op. `depth` bounds pathological nesting. fx_ident is +// queried per FX — chain enumeration is chunk-level, so OFFLINE instances +// match too (a Design-View-parked instance must keep protecting its holds). bool fxSubtreeHasInstance(const FxParmGetter& parm, int fxId, const FxIdentityNeedles& id, int depth) { if (identityMatches(parm(fxId, "fx_ident"), id.uidHexUpper, id.nameUpper, @@ -96,12 +88,9 @@ bool fxSubtreeHasInstance(const FxParmGetter& parm, int fxId, const std::string countStr = parm(fxId, "container_count"); if (countStr.empty()) return false; // not a container; no children to miss if (depth <= 0) { - // This node IS a container but we have exhausted our descent budget. We cannot - // prove that none of its children is a ReaSampler 9000 instance — treat the - // incomplete walk as a positive identification (the protect direction). This is - // defense-in-depth: kMaxContainerDepth = 32 should prevent reaching this branch - // in any real project, but if it IS reached the fail-safe fires rather than - // silently missing a live nested instance. + // Descent budget exhausted on a node that IS a container: we cannot + // prove none of its children is an instance, so treat the incomplete + // walk as a positive identification (protect direction). return true; } const int n = std::atoi(countStr.c_str()); @@ -116,9 +105,9 @@ bool fxSubtreeHasInstance(const FxParmGetter& parm, int fxId, return false; } -// Raised from 8 to 32 (defense in depth against truncation). Real-world FX containers -// are typically 2–4 levels deep; 32 is unreachable in practice while remaining finite. -// Even at 32, the truncation→protect-all guard below is the primary protection. +// Real-world FX containers are typically 2-4 levels deep; 32 is unreachable +// in practice while remaining finite. The truncation->protect-all guard above +// is the primary protection even at this depth. constexpr int kMaxContainerDepth = 32; std::string trackFxParm(MediaTrack* tr, int fxId, const char* parm) { @@ -153,11 +142,9 @@ bool trackHasInstance(MediaTrack* tr, const FxIdentityNeedles& id) { return false; } -// True if any take FX on `item` is a ReaSampler 9000 (all takes, not just active — a -// non-active take's instance still exists in the project and reactivates with the -// take). The SAME identity walk as the track path: fx_ident + original_name + container -// recursion (an unrecognized exotic still lands in the pure protect-all net — records -// with zero identified instances protect everything rather than nothing). +// True if any take FX on `item` is a ReaSampler 9000 (all takes, not just +// active — a non-active take's instance still exists and reactivates with +// the take). Same identity walk as the track path. bool itemHasInstance(MediaItem* item, const FxIdentityNeedles& id) { const int takes = CountTakes(item); for (int t = 0; t < takes; ++t) { @@ -174,15 +161,11 @@ bool itemHasInstance(MediaItem* item, const FxIdentityNeedles& id) { return false; } -// Growing GetProjExtState read: the usage record scales with the hold count, so a -// fixed buffer risks a truncated decode. The retry policy is the SHARED pure -// wire::readProjExtStateGrowing (T2-04 — one loop for persist, this -// prune-safety-adjacent read, and the VST bridge; the rules cannot drift). -// Returns nullopt when the key cannot be read WHOLE — absent-after-enumeration -// (rv <= 0) or pathologically large (> 16 MB give-up). The caller only queries keys -// the enumeration just listed, so a nullopt here is a PRESENT-BUT-UNREADABLE record: -// it folds to abortPrune (fail-safe — silently reduced protection is the delete -// direction). +// The usage record scales with the hold count, so a fixed buffer risks a +// truncated decode; uses the shared grow-loop policy. Returns nullopt when +// the key cannot be read whole (absent, or > 16 MB give-up). The caller only +// queries keys the enumeration just listed, so nullopt here is a +// present-but-unreadable record: it folds to abortPrune. std::optional readExtStateValue(ReaProject* proj, const char* key) { const GrowingExtStateRead read = readProjExtStateGrowing( [&](char* buf, int cap) { @@ -198,10 +181,8 @@ UsageScanResult liveInstanceHeldPaths(void* projOpaque) { ReaProject* proj = static_cast(projOpaque); UsageScanResult result; - // 1. Enumerate the rsusage_* keys and read+decode each record. Key names first - // (values via the growing reader — EnumProjExtState's fixed val buffer could - // truncate a large record). A nullopt element = present-but-unreadable/ - // undecodable -> the pure fold ABORTS the prune. + // Enumerate rsusage_* keys, then read+decode via the growing reader + // (EnumProjExtState's fixed val buffer could truncate a large record). std::vector usageKeys; { const std::string prefix = kProjExtUsageKeyPrefix; // hoisted: one alloc, not N @@ -232,8 +213,8 @@ UsageScanResult liveInstanceHeldPaths(void* projOpaque) { decoded.push_back(rec); // undecodable nullopt -> abort } - // 2. Enumerate live ReaSampler 9000 hosts. One channel-frozen needle set drives - // every match; a track needs only ONE instance to keep all its records live. + // Enumerate live ReaSampler 9000 hosts; a track needs only one instance to + // keep all its records live. FxIdentityNeedles id; id.uidHexUpper = toUpperAscii(vstClassIdHex()); id.outputNameUpper = toUpperAscii(vstOutputName()); @@ -270,14 +251,12 @@ UsageScanResult liveInstanceHeldPaths(void* projOpaque) { } } - // 3. The pure fold decides: abort on any unreadable record; protect-all when zero - // instances were identified; otherwise the per-record liveness rule. + // The pure fold decides: abort on any unreadable record; protect-all when + // zero instances were identified; otherwise the per-record liveness rule. const UsageFoldResult fold = foldUsageRecords(decoded, liveTrackGuids, anyLive); result.abortPrune = fold.abortPrune; result.heldPaths = fold.heldPaths; - // offendingKeys already populated above (unreadable + undecodable entries); - // clear it on success so callers see it only when abortPrune is set. - if (!result.abortPrune) result.offendingKeys.clear(); + if (!result.abortPrune) result.offendingKeys.clear(); // only meaningful on abort return result; } diff --git a/src/shell/persist/usage_scan.h b/src/shell/persist/usage_scan.h index e9fd5d8..8b3b14d 100644 --- a/src/shell/persist/usage_scan.h +++ b/src/shell/persist/usage_scan.h @@ -1,51 +1,37 @@ #pragma once -// usage_scan — the EXTENSION-side shell of the pS-usage seam (see sample_usage.h for -// the pure core, the fail-safe folds, and the full design note). At prune-scan time it -// answers ONE question: which project-relative bank paths are held by a LIVE ReaSampler -// 9000 instance — or must the prune ABORT because a usage record could not be read? +// usage_scan — the extension-side shell of the instance-usage seam (see +// sample_usage.h for the pure core and fail-safe folds). At prune-scan time it +// answers one question: which project-relative bank paths are held by a live +// ReaSampler 9000 instance — or must the prune abort because a usage record +// could not be read? // -// Three reads, no writes (the prune scan's READ-ONLY contract holds): -// 1. Enumerate every "rsusage_" key in the "reasampler" ext-state namespace -// (EnumProjExtState) and decode each record (sample_usage wire). A key that is -// present but cannot be read or decoded folds to abortPrune (fail-safe: an -// unreadable record may protect anything, so the prune halts and deletes nothing). -// 2. Enumerate every ReaSampler 9000 FX instance in the project — all tracks -// (master included), normal + record/input chains, FX containers recursively, and -// take FX (same container recursion) — matching each FX's fx_ident AND -// original_name via the pure sample_usage::identityMatches (class-UID hex, module -// filename base, display name; see the matcher note there). -// 3. Fold with the pure liveness rule (sample_usage::foldUsageRecords / -// usageHeldPaths): a record counts iff its publishing track still hosts >= 1 -// instance; a record with no track context counts while any instance exists; and -// when records exist but ZERO instances were identified anywhere, EVERY record's -// paths are protected (the identity-failure net — a matcher failure must never -// degrade toward delete). +// Three reads, no writes: (1) enumerate every "rsusage_" key and decode +// each record — unreadable/undecodable folds to abortPrune; (2) enumerate +// every ReaSampler 9000 FX instance (all tracks incl. master, normal + +// record/input chains, containers recursively, take FX) via +// sample_usage::identityMatches; (3) fold with the pure liveness rule — zero +// instances identified anywhere protects every record's paths. // -// The result feeds prune_reconcile::mergeReferenced in persist's scanPruneOrphans, so -// `referenced` = bank references ∪ live-instance holds — a held capture can never be -// an orphan, and BANK_PRUNE_FOLDER (the only deletion authority) can never delete it. -// abortPrune propagates through PruneScan/PruneReport to the action, which halts. +// Feeds prune_reconcile::mergeReferenced in persist's scanPruneOrphans — a +// held capture can never be an orphan. abortPrune propagates to the action, +// which halts. // // REAPER-facing: the .cpp includes reaper_plugin_functions.h WITHOUT -// REAPERAPI_IMPLEMENT (main.cpp owns the API pointers — CLAUDE.md §contract). The -// header stays REAPER-free (`proj` is the opaque ReaProject* the persist seam already -// passes around as void*). +// REAPERAPI_IMPLEMENT (main.cpp owns the API pointers). The header stays +// REAPER-free (`proj` is the opaque ReaProject* passed as void*). #include #include namespace reasampler { -// The scan outcome. When abortPrune is true a present rsusage_* record could not be -// read or decoded — the caller MUST halt the prune (delete nothing). offendingKeys -// names the exact "rsusage_" keys that triggered the abort so the action can -// print them for operator recovery (clear via ReaScript: -// reaper.SetProjExtState(0, "reasampler", "", "") -// for each offending key). heldPaths on abort is the protect-all set (every readable -// record's paths) — meaningful only as a belt-and-braces fallback; the abort flag is -// the authoritative signal. Otherwise heldPaths is every project-relative path held by -// a live ReaSampler 9000 instance, de-duped, in record order — empty in the common -// no-records case (the FX enumeration is skipped entirely). +// When abortPrune is true, a present rsusage_* record could not be read or +// decoded — the caller MUST halt the prune. offendingKeys names the exact +// keys that triggered the abort, so the action can print them for recovery +// (clear via ReaScript: reaper.SetProjExtState(0, "reasampler", "", "")). +// heldPaths on abort is the protect-all set — a belt-and-braces fallback; the +// abort flag is authoritative. Otherwise heldPaths is every project-relative +// path held by a live instance, de-duped, in record order. struct UsageScanResult { bool abortPrune = false; std::vector offendingKeys; // non-empty iff abortPrune diff --git a/src/shell/view/view.cpp b/src/shell/view/view.cpp index a427f05..b4af6d2 100644 --- a/src/shell/view/view.cpp +++ b/src/shell/view/view.cpp @@ -1,12 +1,7 @@ -// view.cpp — REAPER-facing Design View shell (Phase D2). See view.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; here they are extern (CLAUDE.md §contract). -// -// The tree arithmetic (I_FOLDERDEPTH -> FolderTree) lives in the pure view_tree -// module so it is unit-tested outside the DAW; this file owns only the REAPER -// reads/writes and the snapshot-before-park ordering. +// See view.h. Compiled into the reaper_reasampler module; includes +// reaper_plugin_functions.h without REAPERAPI_IMPLEMENT (main.cpp owns that). +// Tree arithmetic lives in view_tree (pure); this file owns REAPER reads/writes +// and the snapshot-before-park ordering. #include "shell/view/view.h" @@ -37,8 +32,8 @@ #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. +// Lane minting (D2 Wave 3): item-side lane reads/writes to assign each item to +// its mode's managed lane. #define REAPERAPI_WANT_CountTrackMediaItems #define REAPERAPI_WANT_GetTrackMediaItem #define REAPERAPI_WANT_GetMediaItemInfo_Value @@ -47,7 +42,6 @@ namespace reasampler { -// Real-namespace-home using-declarations (Q-W6: the namespaces.h shim is retired). using view::buildFolderTree; using view::isOnManualLane; using view::managedLaneKey; @@ -56,35 +50,24 @@ using view::TrackFolderEntry; namespace { -// Track fixed-lane mode value (I_FREEMODE=2). See SDK: 0=normal, 1=free item -// positioning, 2=fixed lanes. +// I_FREEMODE value for fixed lanes. 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. +// C_LANESCOLLAPSED=2: render a tool-split track like a normal single-lane +// track showing only the playing lane (SDK: 1=collapsed, 2=hidden-lanes-exist +// but displays as non-fixed-lane). 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. +// C_LANESETTINGS &32 = hide per-lane buttons; OR'd in, never clobbering the +// mask. Deliberately NOT setting &1 (auto-remove empty lanes): the lazy-mint +// decision never mints an empty lane, so &1 buys nothing and risks REAPER +// silently removing a managed lane out from under the ownership index. 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. +// Makes a tool-split track's display read as an ordinary track. Gated by every +// caller on the tool-driven transition INTO fixed lanes (freeMode != 2 before +// the flip) — a track already in fixed-lane mode (the user's own) never +// reaches this, so a user's comp-lane display prefs are never stomped. void applyTransparentLaneDisplay(MediaTrack* tr) { SetMediaTrackInfo_Value(tr, "C_LANESCOLLAPSED", static_cast(kLanesDisplayAsNormal)); @@ -93,8 +76,6 @@ void applyTransparentLaneDisplay(MediaTrack* tr) { static_cast(settings | kLaneSettingsHideButtons)); } -// The parmname for each planner Flag. All four are documented bool*/int* track -// info params driven through the double-valued Get/SetMediaTrackInfo_Value API. const char* flagParm(Flag f) { switch (f) { case Flag::ShowInTcp: return "B_SHOWINTCP"; @@ -105,11 +86,9 @@ const char* flagParm(Flag f) { return "B_SHOWINTCP"; // unreachable; keeps the compiler quiet } -// Reads the arrange-ordered track list and their I_FOLDERDEPTH, keyed by GUID. -// The master track is NOT enumerated by GetTrack (index space is the non-master -// tracks), so it can never enter the tree — the master-untouched invariant holds -// by construction. Also caches the MediaTrack* per GUID so later apply steps -// resolve a GUID back to its handle without a second linear scan. +// The master track is not enumerated by GetTrack (index space excludes it), +// so it can never enter the tree — the master-untouched invariant holds by +// construction. Also caches each MediaTrack* by GUID for later resolve(). std::vector readFolderEntries( ReaProject* proj, std::vector>& handleByGuid) { @@ -137,10 +116,8 @@ MediaTrack* resolve(const std::vector>& hand return nullptr; // stale/deleted GUID — pruned by being skipped } -// Captures a track's prior driven-flag state BEFORE it is parked. Reads only the -// four owned flags + per-FX offline; never B_MUTE/I_SOLO, never the master (not -// reachable here). ints preserve whatever REAPER reported (defensive per D1's -// TrackSnapshot contract). +// Captures prior driven-flag state before parking. Never reads B_MUTE/I_SOLO; +// ints preserve whatever REAPER reported (TrackSnapshot's defensive contract). TrackSnapshot snapshotTrack(MediaTrack* tr) { TrackSnapshot snap; snap.showInTcp = static_cast(GetMediaTrackInfo_Value(tr, "B_SHOWINTCP")); @@ -156,16 +133,14 @@ TrackSnapshot snapshotTrack(MediaTrack* tr) { return snap; } -// Applies the planner's scalar-flag writes. B_* are bool* params, I_FXEN is int*, -// all driven through the double API — marshal the plan's int value to double. void applyFlags(MediaTrack* tr, const std::vector& flags) { for (const TrackFlagOp& op : flags) { SetMediaTrackInfo_Value(tr, flagParm(op.flag), static_cast(op.value)); } } -// Parks a track's FX offline: the pure park plan leaves fxOffline empty by design; -// the shell expands it from the live FX count and offlines every slot. +// The pure park plan leaves fxOffline empty by design; expand it here from the +// live FX count. void parkFxOffline(MediaTrack* tr) { int fxCount = TrackFX_GetCount(tr); for (int fx = 0; fx < fxCount; ++fx) { @@ -173,15 +148,13 @@ void parkFxOffline(MediaTrack* tr) { } } -// Restores per-FX offline from the snapshot verbatim — each slot back to its -// captured value, never a blanket "online". Bounds-checked against the live FX -// count in case the plugin chain changed while parked (prune-safe). +// Restores per-FX offline from the snapshot, bounds-checked against the live +// FX count (prune-safe if the chain changed while parked). // -// HAZARD (deferred, PLAN "reconcile on delete/restructure"): the remap is by -// slot INDEX, not plugin identity. If the FX chain changed while the track was -// parked, snapshot slot k is restored onto whatever plugin now occupies slot k — -// the bounds-check guards against out-of-range, not against a reshuffled chain. -// Acceptable for D2; full identity-based reconciliation is future hardening. +// HAZARD (open, tracked in docs/TODO.md): this remaps by slot INDEX, not +// plugin identity. If the FX chain reshuffled while parked, snapshot slot k +// restores onto whatever plugin now occupies slot k. Accepted for now; +// identity-based reconciliation is future hardening. void restoreFxOffline(MediaTrack* tr, const std::vector& fxOffline) { int fxCount = TrackFX_GetCount(tr); for (const FxOfflineOp& op : fxOffline) { @@ -190,18 +163,14 @@ void restoreFxOffline(MediaTrack* tr, const std::vector& fxOffline) } } -// -- Managed-lane application (D2 Wave 2) ------------------------------------ -// -// The pure planner emits LanePlayOps keyed by (trackGuid, laneKey) where laneKey is -// the lane's DURABLE name (lane_keys convention: "reasampler:"). REAPER's -// C_LANEPLAYS:N is keyed by the lane's CURRENT ORDINAL, which renumbers on reorder. -// So before applying, we build the ordinal<->key reconcile for a track by reading each -// lane's P_LANENAME:n; the write then targets the correct current ordinal for a given -// durable key even after a reorder (design point #2). A lane whose name lacks the -// managed prefix is manual and never appears in this map, so it can never be driven. +// Managed-lane application: the pure planner keys LanePlayOps by the lane's +// DURABLE name; REAPER's C_LANEPLAYS:N is keyed by current ordinal, which +// renumbers on reorder. So every write here re-resolves durable key -> current +// ordinal first. A lane whose name lacks the managed prefix never enters this +// map and so 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). +// Lane `laneIdx`'s durable name (P_LANENAME:n) on `tr`, or empty if unnamed / +// unavailable (non-fixed-lane track). std::string laneName(MediaTrack* tr, int laneIdx) { char parm[32]; std::snprintf(parm, sizeof(parm), "P_LANENAME:%d", laneIdx); @@ -210,9 +179,8 @@ std::string laneName(MediaTrack* tr, int laneIdx) { 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. +// Managed lane durable key -> current ordinal on `tr`. Manual lanes are +// omitted, so a key absent from the map must not be driven. std::map managedLaneOrdinals(MediaTrack* tr) { std::map byKey; const int numLanes = static_cast(GetMediaTrackInfo_Value(tr, "I_NUMFIXEDLANES")); @@ -223,37 +191,25 @@ std::map managedLaneOrdinals(MediaTrack* tr) { 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.) +// Track-side C_LANEPLAYS:N alone hides+silences every item on lane N (SDK: +// item-side C_LANEPLAYS is read-only, so no per-item write exists or is +// needed). B_FIXEDLANE_HIDDEN is also read-only — hide/show follows from +// C_LANEPLAYS=0/1, never written directly. void applyLanePlays(MediaTrack* tr, int laneIdx, int lanePlays) { char parm[32]; std::snprintf(parm, sizeof(parm), "C_LANEPLAYS:%d", laneIdx); SetMediaTrackInfo_Value(tr, parm, static_cast(lanePlays)); } -// 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. +// Groups ops by track, reconciles each op's durable laneKey to the track's +// current ordinal (a stale/renamed/deleted key is pruned, never mis-driven), +// enables fixed-lane mode on any track carrying a managed lane, and drives +// C_LANEPLAYS. UpdateTimeline() is the caller's job when this returns true +// (SDK: required after an I_FREEMODE change). bool applyLaneOps(const std::vector>& handleByGuid, const std::vector& lanes) { if (lanes.empty()) return false; - // Group op indices by track guid so we read each track's lane map once. std::map> byTrack; for (const LanePlayOp& op : lanes) byTrack[op.trackGuid].push_back(&op); @@ -262,22 +218,18 @@ bool applyLaneOps(const std::vector>& handle 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. + // Every track reaching here already owns a managed lane (planToggle + // only emits ops for managed lanes), so re-asserting fixed-lane mode + // is always a tool-driven (re)split — never a user's untouched + // manual-fixed-lane track — and gets the same transparent display. const int freeMode = static_cast(GetMediaTrackInfo_Value(tr, "I_FREEMODE")); if (freeMode != kFreeModeFixedLanes) { SetMediaTrackInfo_Value(tr, "I_FREEMODE", static_cast(kFreeModeFixedLanes)); - applyTransparentLaneDisplay(tr); // tool-managed track ⇒ read like a normal track + applyTransparentLaneDisplay(tr); touchedFreeMode = true; } - // Reconcile durable keys -> current ordinals on THIS track, then drive each op. const std::map ordinals = managedLaneOrdinals(tr); for (const LanePlayOp* op : ops) { auto it = ordinals.find(op->laneKey); @@ -288,20 +240,12 @@ bool applyLaneOps(const std::vector>& handle return touchedFreeMode; } -// -- Managed-lane minting (D2 Wave 3) ---------------------------------------- -// -// Mints one managed fixed lane per mode on any track that now holds content of MORE -// THAN ONE mode, and assigns each item to its mode's managed lane. The DECISION — -// which tracks split, which lanes to mint, which item goes where — is the pure -// planLaneMinting; this shell only reads live per-item mode+lane state, calls the -// decision, and applies the resulting REAPER + ownership-index writes. +// Managed-lane minting: the DECISION (which tracks split, which lanes, which +// item goes where) is planLaneMinting; this shell only reads live per-item +// mode+lane state, calls it, and applies the resulting 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). +// Maps every item GUID on `tr` to its handle in one pass (avoids a per-item +// re-scan in the assign loop). std::map itemHandlesByGuid(MediaTrack* tr) { std::map byGuid; const int itemCount = CountTrackMediaItems(tr); @@ -314,23 +258,18 @@ std::map itemHandlesByGuid(MediaTrack* tr) { 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. +// An untagged item is Arrange by default (mirrors leafBelongsToMode). A +// show-both/multi-mode item resolves to its first mode id — unusual for lane +// content, and any one mode is sufficient for the decision. std::string itemModeFromMembership(const ViewModeModel& model, const std::string& itemGuid) { const std::set modes = model.membership().modesOf(itemGuid); - if (modes.empty()) return kArrangeModeId; // untagged ⇒ Arrange default + if (modes.empty()) return kArrangeModeId; 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. +// Builds the per-track LaneItem picture the pure decision consumes. Manual- +// lane reads are skipped on a non-fixed-lane track (isOnManualLane is false +// there regardless of name). std::vector readLaneTracks( const ViewModeModel& model, const std::vector>& handleByGuid) { @@ -353,9 +292,6 @@ std::vector readLaneTracks( 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)); @@ -365,12 +301,9 @@ std::vector readLaneTracks( 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".) +// Idempotent: writes I_FIXEDLANE only when it differs from the item's current +// lane. Non-destructive — only this reversible flag is written, never a move +// in time or across tracks. bool assignItemToLane(MediaTrack* tr, MediaItem* it, int laneOrdinal) { const int current = static_cast(GetMediaItemInfo_Value(it, "I_FIXEDLANE")); if (current == laneOrdinal) return false; // already there — no-op @@ -378,22 +311,16 @@ bool assignItemToLane(MediaTrack* tr, MediaItem* it, int 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). +// Applies the pure LaneMintPlan. Returns true if any project write actually +// changed state (⇒ 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. +// The plan only ever names managed-prefixed lanes and only ever assigns +// managed-eligible items; I_NUMFIXEDLANES is only ever GROWN, never shrunk, +// so a user's existing manual lanes are never renamed or reassigned. bool applyMintPlan(ViewModeModel& model, const LaneMintPlan& plan, const std::vector>& handleByGuid) { bool changed = false; - // Group mints + assigns by track so each track is set up once. std::map> mintsByTrack; for (const LaneMint& m : plan.mints) mintsByTrack[m.trackGuid].push_back(&m); std::map> assignsByTrack; @@ -403,37 +330,26 @@ bool applyMintPlan(ViewModeModel& model, const LaneMintPlan& plan, MediaTrack* tr = resolve(handleByGuid, split.trackGuid); if (!tr) continue; // stale GUID — prune - // Enable fixed-lane mode if not already (SDK: UpdateTimeline() owed after). 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. + // A track not already in fixed-lane mode is one the tool is splitting + // now, so it owns the display; a track already at I_FREEMODE==2 (the + // user's own, or a prior tool run) skips this and keeps its display prefs. const int freeMode = static_cast(GetMediaTrackInfo_Value(tr, "I_FREEMODE")); if (freeMode != kFreeModeFixedLanes) { SetMediaTrackInfo_Value(tr, "I_FREEMODE", static_cast(kFreeModeFixedLanes)); - applyTransparentLaneDisplay(tr); // tool-split track ⇒ read like a normal track + applyTransparentLaneDisplay(tr); 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. + // Grow-only: a track may already carry the user's manual lanes, so the + // lane count only ever increases; managed lanes occupy the tail ordinals. int laneCount = static_cast(GetMediaTrackInfo_Value(tr, "I_NUMFIXEDLANES")); - // Which managed keys are already present on this track (durable-name reconcile). std::map present = managedLaneOrdinals(tr); - // Mint each managed lane that is not already present, appending at the tail so an - // existing manual lane is never overwritten. Record ownership in the model. 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(laneCount)); char parm[32]; @@ -445,10 +361,6 @@ bool applyMintPlan(ViewModeModel& model, const LaneMintPlan& plan, changed = true; } - // Assign each item to its mode's managed lane, resolving the durable key to the - // lane's current ordinal on THIS track. A key not present (shouldn't happen — we - // just minted them all) is skipped rather than mis-assigned. Item handles are - // resolved through a one-pass GUID map (avoids re-scanning the track per item). const std::map ordinals = managedLaneOrdinals(tr); const std::map itemsByGuid = itemHandlesByGuid(tr); for (const LaneAssign* a : assignsByTrack[split.trackGuid]) { @@ -465,21 +377,18 @@ bool applyMintPlan(ViewModeModel& model, const LaneMintPlan& plan, } // namespace bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject* proj) { - // Reject an unregistered target before touching the project (no partial apply). if (!model.modes().contains(targetModeId)) { - return false; + return false; // reject before touching the project — no partial apply } std::vector> handleByGuid; std::vector entries = readFolderEntries(proj, handleByGuid); FolderTree tree = buildFolderTree(entries); - // Reconcile orphaned model state BEFORE planning: prune snapshots whose track was - // deleted from the project (its GUID no longer appears in the live enumeration). - // handleByGuid holds every currently-enumerated track GUID, so its keys are the - // authoritative live set. Membership is intentionally NOT pruned (undo-delete - // restores the same GUID — see ViewModeModel::reconcile). Because reapply-on-load - // routes through applyMode, this also reconciles on project open. + // Prune snapshots for tracks no longer in the live enumeration before + // planning (membership is intentionally left alone — see model.reconcile). + // Because reapply-on-load routes through applyMode, this also reconciles + // on project open. std::set liveGuids; for (const auto& kv : handleByGuid) liveGuids.insert(kv.first); model.reconcile(liveGuids); @@ -488,31 +397,25 @@ bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject Undo_BeginBlock2(proj); - // PARK: snapshot BEFORE mutating, store into the model (so restore survives a - // save-while-parked), then apply the park writes + expand the FX-offline loop. + // PARK: snapshot before mutating, store into the model, then apply. for (const TrackPlan& tp : plan.park) { - // Every op in a TrackPlan targets the same track; take the guid from the - // first flag op (the pure park plan always emits the four flag ops). - if (tp.flags.empty()) continue; + if (tp.flags.empty()) continue; // every op in a TrackPlan targets one track const std::string& guid = tp.flags.front().guid; MediaTrack* tr = resolve(handleByGuid, guid); if (!tr) continue; // stale GUID — prune - // Snapshot ONCE, at the first park. If a snapshot already exists the track is - // still parked from a prior apply, and its live flags are the PARKED (hidden) - // values — recapturing here would overwrite the true pre-park state with zeros, - // so a later restore would restore the track to hidden and it would vanish for - // good. Re-applying the park flags to an already-parked track is idempotent and - // fine; only the snapshot must not be recaptured. Restore clears the snapshot, - // so the next genuine park recaptures fresh state. + // Snapshot ONCE, at first park: a snapshot already present means the + // track is still parked from a prior apply, so its live flags are the + // parked values — recapturing would overwrite the true pre-park state + // with zeros and a later restore would hide it for good. Restore + // clears the snapshot, so the next genuine park recaptures fresh state. if (model.snapshot(guid) == nullptr) model.storeSnapshot(guid, snapshotTrack(tr)); applyFlags(tr, tp.flags); parkFxOffline(tr); } - // RESTORE: apply the snapshot-sourced flag + per-FX offline writes verbatim, - // then drop the now-consumed snapshot so a re-park recaptures fresh state. + // RESTORE: apply verbatim, then drop the consumed snapshot. for (const TrackPlan& tp : plan.restore) { if (tp.flags.empty()) continue; const std::string& guid = tp.flags.front().guid; @@ -524,21 +427,13 @@ 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. + // MANAGED LANES: drive C_LANEPLAYS so the active mode's lane plays+shows + // and every other managed lane is silenced+hidden. Empty for a D1-only + // project, leaving that behavior byte-identical. 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 - // every toggle rather than snapshotted. Drive only the two visibility flags; - // never touch B_MAINSEND/I_FXEN/FX-offline on a parent. + // PARENT VISIBILITY (never parked): recomputed every toggle, never + // snapshotted. Only the two visibility flags — never mainSend/FX on a parent. std::set visible = model.visibleTracks(tree, targetModeId); for (const FolderNode& node : tree.nodes) { if (!node.isParent) continue; @@ -549,10 +444,8 @@ bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject SetMediaTrackInfo_Value(tr, "B_SHOWINMIXER", show); } - // Build the undo label from the ACTUAL target mode's display name, so activating - // Arrange doesn't leave an "activate Design view" undo point (and vice versa). - // The target is guaranteed registered (checked at entry), so query() is non-null; - // fall back to the id defensively if that ever changes. + // Target is guaranteed registered (checked at entry); fall back to the id + // defensively if that ever changes. const Mode* targetMode = model.modes().query(targetModeId); const std::string undoLabel = "ReaSampler: activate " + @@ -560,18 +453,14 @@ bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject model.setActiveMode(targetModeId); - // Force REAPER to rebuild the TCP + MCP so visibility/park changes appear now, - // not on the user's next TCP interaction. TrackList_AdjustWindows(false) does the - // major (full) relayout required when tracks appear/disappear from the panels; - // UpdateArrange() repaints the arrange view. Both are documented for exactly this - // "you changed track-info flags, now refresh the panels" case. + // Force REAPER to rebuild the TCP/MCP now rather than on the next user + // interaction: TrackList_AdjustWindows(false) does the full relayout owed + // when tracks appear/disappear; UpdateArrange() repaints. 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. + // UpdateTimeline() is owed only when a track was actually toggled into + // fixed lanes this apply (SDK requirement for I_FREEMODE changes). if (laneModeChanged) UpdateTimeline(); Undo_EndBlock2(proj, undoLabel.c_str(), -1); @@ -581,63 +470,41 @@ bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject bool mintManagedLanes(ViewModeModel& model, ReaProject* proj) { std::vector> handleByGuid; std::vector entries = readFolderEntries(proj, handleByGuid); - // The minting decision is now folder-tree / visibility aware: it needs the tree to - // detect a content-bearing folder derived-visible in >1 mode (which must lane-separate - // its own media even when that media is single-mode). Build it exactly as applyMode does. + // The tree is needed to detect a content-bearing folder derived-visible in + // >1 mode, exactly as applyMode builds it. 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 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. + // Plan was non-empty but every write was already satisfied — discard + // the empty undo point rather than flooding history every detect 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. + // The arrange still needs a redraw: this no-op path is reached when a + // freshly-inserted item already landed on the active mode's playing + // lane (REAPER places new items on the playing lane), so + // assignItemToLane wrote nothing even though the item needs to appear + // there now. UpdateArrange() alone (no I_FREEMODE change happened, so + // UpdateTimeline isn't owed) is a repaint, not a mutation — stays + // outside the undo block. 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. + // Reapply the active mode's lane visibility so freshly-minted lanes take + // their play/show state immediately. applyMode is deliberately NOT reused + // here — it would re-park/restore whole tracks and recompute parent + // visibility, which a lane-only mint must not touch. 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(); + UpdateTimeline(); // a split happened this call — refresh is owed UpdateArrange(); Undo_EndBlock2(proj, "ReaSampler: separate cross-mode content into lanes", -1); @@ -648,12 +515,9 @@ void reconcileManagedLanes(ViewModeModel& model, ReaProject* proj) { std::vector> handleByGuid; readFolderEntries(proj, handleByGuid); // populates handleByGuid (tree unused here) - // Walk every track's lanes; for each lane whose durable name carries the managed - // prefix, record it MANAGED-for-its-mode in the ownership index. This is a pure READ - // of REAPER state (no lane is created, no I_FREEMODE/I_NUMFIXEDLANES/I_FIXEDLANE is - // written) plus an index write — self-healing classification from the source of - // truth (the durable name) without re-minting or mass-tagging. A lane lacking the - // prefix is left alone (manual by default), so a user's own lanes stay off the index. + // Pure read of REAPER state (no lane created, no I_FREEMODE/I_NUMFIXEDLANES/ + // I_FIXEDLANE written) plus an ownership-index write, recovering managed + // classification from the durable name. An unprefixed lane is left alone. for (const auto& [guid, tr] : handleByGuid) { const int freeMode = static_cast(GetMediaTrackInfo_Value(tr, "I_FREEMODE")); if (freeMode != kFreeModeFixedLanes) continue; // no fixed lanes ⇒ nothing managed @@ -666,15 +530,12 @@ void reconcileManagedLanes(ViewModeModel& model, ReaProject* proj) { std::optional mode = modeIdFromLaneName(name); if (!mode) continue; // prefix-only/illegal name — skip defensively - // UNREGISTERED-MODE GUARD: the durable name encodes a mode id, but that mode - // may no longer be a registered Mode (e.g. a mode removed from the registry - // after the project was saved with lanes minted for it). Recording it MANAGED - // would make the toggle planner drive a lane keyed to a mode that can never be - // the active mode — the lane would stay silenced+hidden forever, orphaning its - // items with no way for the user to reach them. So we do NOT record it: the - // lane is left off the ownership index and thus treated as manual-by-default - // (never driven). Its durable name is preserved on the track, so if the mode is - // ever re-registered a later reconcile recovers the ownership cleanly. + // A mode id encoded in the name may no longer be registered (e.g. + // removed since the project was saved). Recording it managed would + // make the toggle planner drive a lane keyed to a mode that can + // never be active — permanently silenced, orphaning its items. So + // skip: the lane stays off the index (manual-by-default) but keeps + // its name, so a later re-registration of the mode heals cleanly. if (!model.modes().contains(*mode)) continue; model.lanes().setManaged(guid, *key, *mode); } diff --git a/src/shell/view/view.h b/src/shell/view/view.h index 9260cd7..856a0cd 100644 --- a/src/shell/view/view.h +++ b/src/shell/view/view.h @@ -1,95 +1,37 @@ #pragma once -// view — the REAPER-facing shell of the Design View feature (Phase D2). It is the -// mirror of the capture shell: the ViewModeModel (pure, D1) holds the mode/ -// membership/snapshot state and emits the toggle plan; this shell reads the live -// project's folder tree, snapshots the tracks it is about to park, runs the model's -// planner, and applies the resulting flag + per-FX-offline writes to REAPER. -// -// It includes view_mode_model (pure) but NO REAPER headers — the .cpp is the one -// REAPER-facing translation unit (CLAUDE.md §contract: only main.cpp defines the -// API pointers; every other .cpp gets them extern). Callers (persist, actions) -// depend on this seam without dragging the SDK into their include sites. -// -// Hard invariants this shell enforces (CONTEXT.md §Design View, precision -// invariants) — verified in self-review, never crossed: -// * Never touches the master track's visibility (SDK forbids B_SHOWINTCP/ -// B_SHOWINMIXER on master); the master is never a node in the tree. -// * Never reads or writes B_MUTE / I_SOLO on any track. -// * Manages ALL leaves via the mode system: an untagged leaf is an Arrange member, -// so it is fully parked in non-Arrange modes and restored in Arrange, identically -// to a tagged leaf. show-both is the always-visible escape; parents are -// visibility-only (derived); the master is never touched. -// * Snapshots every to-be-parked track's prior flags BEFORE parking, storing -// them into the model so restore is faithful and survives a save-while-parked. +// REAPER-facing shell of Design View (D2): reads the live folder tree, runs +// ViewModeModel's pure planner, and applies the resulting flag / per-FX / +// lane writes. The .cpp is the sole REAPER-facing TU here (CLAUDE.md contract: +// only main.cpp defines the API pointers). See src/shell/view/CLAUDE.md for +// the enforced invariants (never touch master/mute/solo, snapshot-based restore). #include #include "core/view/view_mode_model.h" -// REAPER's opaque project handle. Forward-declared to keep this header SDK-free; -// the .cpp includes reaper_plugin_functions.h and sees the real class. +// Forward-declared to keep this header SDK-free; the .cpp includes the real SDK header. class ReaProject; namespace reasampler { -// Applies `targetModeId` to the live project `proj`: -// 1. Reads the arrange-ordered track list, builds the FolderTree from -// I_FOLDERDEPTH (via the pure buildFolderTree helper). -// 2. Runs model.planToggle(tree, targetModeId). -// 3. For each track about to be PARKED: snapshots its current B_SHOWINTCP / -// B_SHOWINMIXER / B_MAINSEND / I_FXEN and per-FX offline state, stores the -// snapshot into the model, THEN applies the park writes (expanding the -// per-FX offline loop from TrackFX_GetCount, which the pure plan leaves empty). -// 4. For each track to RESTORE: applies the plan's snapshot-sourced flag + per-FX -// offline writes verbatim. -// 5. For each PARENT (folder) node: drives B_SHOWINTCP / B_SHOWINMIXER to 1 if the -// parent is in model.visibleTracks(tree, targetModeId), else 0 — derived from -// membership, never parked/snapshotted. Only the two visibility flags. -// 6. Sets the model's active mode to `targetModeId`. -// All track mutations are wrapped in Undo_BeginBlock2 / Undo_EndBlock2. -// -// Returns false (no mutation, active mode unchanged) if `targetModeId` is not a -// registered mode. `proj` may be nullptr to mean REAPER's current project. +// Snapshots each about-to-park track's flags into `model`, runs planToggle, +// applies park/restore writes plus parent visibility flags, then sets the +// active mode. Wrapped in one Undo block. Returns false (no mutation) if +// `targetModeId` isn't registered. `proj` == nullptr means the 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. +// Splits any track visible in more than one mode while carrying its own media +// into fixed lanes (one managed lane per involved mode), assigns items, and +// records ownership in `model`. Never touches manual lanes. Runs planLaneMinting +// (model + tree aware); wraps the mutation in one Undo block when non-empty. +// Returns true if any lane was minted (repaint hint). `proj` == nullptr means +// the current project. 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. +// Recovers managed-lane ownership from durable P_LANENAME on project open — +// self-healing, without minting/reassigning anything and without touching +// membership. A lane without the managed prefix is left untouched (manual). +// `proj` == nullptr means the current project. void reconcileManagedLanes(ViewModeModel& model, ReaProject* proj); } // namespace reasampler